Compare commits

...

10 Commits

Author SHA1 Message Date
603fd45eaa Overhaul of the frontend on waiters, orders, and payment events
Manager Dashboard: product reorder/bulk actions, preference sub-choices
UI, expanded reports with DateInput component, waiter management updates,
order detail improvements, Docker config and backend dockerignore added.

Backend: table groups, auto-numbering, has_active_order flag, expanded
reporting endpoints, waiter zone management, user schema updates, system
router additions, table router fixes.

Waiter PWA: TableDetailPage order/payment event improvements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:37:34 +03:00
ee51e52acf Waiter PWA: UX polish — table names, category colours, print ack, PIN fix
- TableCard: show table.label (display name) instead of internal number
- TableListPage: zone filter rows 50% taller; table cards capped at 132px
  max-height so single-table zones don't stretch; grid aligns to top
- ProductPicker: category tabs use their configured colour (inactive=35%
  opacity); new View All button opens a full-screen category tile modal
- AddItemsPage: show per-printer print acknowledgement after sending order;
  print failures keep items as drafts and show a clear error screen
- PinPad: reduced to 4 dots/digits with auto-submit on 4th digit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:35:22 +03:00
26c4818aa1 Backend: synchronous print status on add_items
add_items now runs printer routing synchronously and returns
{ order, print_results } so the waiter PWA can show per-printer
ack or failure without guessing. Extracted _do_route_and_print
so the background-task path (route_and_print) is unchanged for
other callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:35:08 +03:00
da29d73520 chore: gitignore FISCAL-DRIVER-TESTS/ and data/
FISCAL-DRIVER-TESTS/ contains vendor driver docs and test files that
don't belong in source control. data/ holds the SQLite databases and
uploaded product images — runtime-generated, not source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:33:45 +03:00
c92fc27ad0 Docs: fix whitespace alignment in PROJECT_REFERENCE.md ASCII diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:32:13 +03:00
97d72a5868 Manager Dashboard: product reorder/bulk actions, preference sub-choices UI
ProductsPage:
- Drag-free reorder via ▲/▼ buttons (calls PUT /products/reorder)
- Sort toolbar: custom order / name / price
- Show/hide inactive products toggle
- Multi-select mode with bulk available/unavailable/hard-delete actions
- Preference sets: default_choice_index picker, shared_subset editor
  (set-level sub-choices shared across choices, with disables_subset per choice)
- Inline sub-choice editor for both checkbox options and preference choices
  (SubChoiceRows component with reorder, default toggle, price stepper)
- Hard delete + soft deactivate distinction in the delete confirmation dialog
- PriceInput component with −/+ steppers (0.10 increments)

TablesPage:
- Table groups gain prefix and color fields in create/edit form
- Color swatch picker for group color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:29:24 +03:00
5acd880e92 Waiter PWA: sub-choices in modal, zone filter, login JSON fix
ItemOptionsModal:
- Supports inline sub-choices on both preference choices and checkbox options;
  sub-choices appear indented when the parent is selected
- Supports shared_subset at the preference-set level (shown unless the selected
  choice has disables_subset)
- Pre-selects default choices and their default sub-choices on open
- Add button disabled + red validation hints until all required selections made
- Price total reflects sub-choice extra_cost

TableListPage:
- Zone filter dropdown with multi-select; filters by table group_id
  (fetches /api/tables/groups alongside tables and orders)
- Fixed 'mine' and 'free' filters to compose correctly with zone filter

LoginPage:
- Switch to JSON body { username, pin } to match updated /api/auth/login
- Read user fields from data.user.* instead of flat response

vite.config.js: enable SW devOptions so PWA works in dev mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:29:09 +03:00
d07c7634e6 Backend: table groups with prefix/color, auto-numbering, has_active_order flag
TableGroup gains prefix and color columns for display in the PWA zone filter.
Table creation now assigns a global auto-increment number; batch creation uses
group-local label numbering (avoids gaps/conflicts when adding to existing groups).
DELETE table now blocks if an active order exists (soft or hard delete).
Hard delete cascades past orders before removing the table row.
list_tables enriches each TableOut with has_active_order computed server-side.
TableOut no longer requires number in the input payload; TableCreate simplified.

Migration runner refactored to give each ALTER TABLE its own connection so a
no-op (column already exists) doesn't leave a dirty transaction blocking later
migrations. New migrations added for all new columns.

Order.print_logs relationship gains cascade="all, delete-orphan" so print logs
are removed when an order is deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:28:56 +03:00
2c9276e654 Backend: product sub-choices, sort_order, preference shared_subset, hard-delete
- ProductOption and ProductPreferenceChoice gain sub_choices (JSON Text column)
  for nested inline choices shown when the parent is selected
- ProductPreferenceSet gains default_choice_id and shared_subset (set-level
  sub-choice group shown for all choices that don't disable it)
- Product gains sort_order column; list endpoint orders by sort_order
- New PUT /products/reorder endpoint for drag-and-drop ordering
- DELETE /products/{id} now accepts ?hard=true for permanent deletion (blocked
  if product appears in any past order)
- Schemas updated with model_validators to parse stored JSON back to typed objects
- Add python-multipart to requirements (needed for file upload form parsing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:27:16 +03:00
5dbb775308 Backend: move JWT logic to deps.py, fix circular import in auth
Token creation/decoding/blacklisting was split across auth.py and deps.py
causing a circular import. Consolidate make_token, decode_token, and
blacklist_token in deps.py; auth.py now imports from there.
Also switches /login to accept JSON body (username+pin) instead of
form-encoded, and returns a proper user object in the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:26:49 +03:00
41 changed files with 4489 additions and 806 deletions

6
.gitignore vendored
View File

@@ -26,3 +26,9 @@ dist/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Fiscal driver tests & documentation
FISCAL-DRIVER-TESTS/
# Runtime data (databases, uploaded images)
data/

View File

@@ -29,7 +29,7 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
│ sysadmin_panel (React/Vite, port 5175) │ │ sysadmin_panel (React/Vite, port 5175) │
│ │ │ │
│ Responsibilities: │ │ Responsibilities: │
│ - Site registration (generates site_id + key) │ │ - Site registration (generates site_id + key)
│ - License management (expiry dates) │ │ - License management (expiry dates) │
│ - Remote lock/unlock per site │ │ - Remote lock/unlock per site │
│ - Receives periodic heartbeats from local sites │ │ - Receives periodic heartbeats from local sites │
@@ -42,7 +42,7 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
│ backend (FastAPI, port 8000) │ │ backend (FastAPI, port 8000) │
│ SQLite DB (pos.db) │ │ SQLite DB (pos.db) │
│ Runs on: Raspberry Pi 4 or any Linux box │ │ Runs on: Raspberry Pi 4 or any Linux box │
│ Static LAN IP (e.g. 192.168.1.10) │ │ Static LAN IP (e.g. 192.168.1.10)
│ │ │ │
│ Responsibilities: │ │ Responsibilities: │
│ - All business logic (orders, tables, users) │ │ - All business logic (orders, tables, users) │
@@ -51,12 +51,12 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
│ - License enforcement (from cloud heartbeat) │ │ - License enforcement (from cloud heartbeat) │
└───────────┬──────────────────┬───────────────────┘ └───────────┬──────────────────┬───────────────────┘
│ LAN │ LAN │ LAN │ LAN
┌───────────▼──────┐ ┌────────▼──────────────────┐ ┌───────────▼──────┐ ┌────────▼──────────────────
│ waiter_pwa │ │ manager_dashboard │ │ waiter_pwa │ │ manager_dashboard │
│ React PWA │ │ React web app │ │ React PWA │ │ React web app │
│ Port 5173 │ │ Port 5174 │ │ Port 5173 │ │ Port 5174 │
│ Waiters' phones │ │ Supervisor tablet/laptop │ │ Waiters' phones │ │ Supervisor tablet/laptop │
└───────────┬──────┘ └───────────────────────────┘ └───────────┬──────┘ └───────────────────────────
│ LAN (TCP/9100) │ LAN (TCP/9100)
┌───────────▼───────────────────────────────────────┐ ┌───────────▼───────────────────────────────────────┐
│ Thermal Printers (Jolimark TP850UE) │ │ Thermal Printers (Jolimark TP850UE) │

View File

@@ -21,6 +21,7 @@ services:
- ./local_backend/license_state.json:/app/license_state.json - ./local_backend/license_state.json:/app/license_state.json
- ./logo.png:/app/logo.png:ro - ./logo.png:/app/logo.png:ro
- ./data/product_images:/app/data/product_images - ./data/product_images:/app/data/product_images
- ./data/avatars:/app/data/avatars
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@@ -0,0 +1,5 @@
pos.db
license_state.json
__pycache__
*.pyc
.env

View File

@@ -9,35 +9,78 @@ from middleware.license_check import LicenseCheckMiddleware
from services.cloud_sync import start_cloud_sync from services.cloud_sync import start_cloud_sync
# Import all models so SQLAlchemy can create their tables # Import all models so SQLAlchemy can create their tables
import models.user # noqa: F401 import models.user # noqa: F401 — also registers WaiterZone
import models.table # noqa: F401 import models.table # noqa: F401
import models.printer # noqa: F401 import models.printer # noqa: F401
import models.product # noqa: F401 import models.product # noqa: F401
import models.order # noqa: F401 import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
from routers import auth, tables, products, orders, waiters, reports, system from routers import auth, tables, products, orders, waiters, reports, system
def _run_migrations(): def _run_migrations():
"""Apply additive schema changes that create_all won't handle.""" """Apply additive schema changes that create_all won't handle.
Each migration gets its own connection so a no-op (column already exists)
doesn't leave a dirty transaction that blocks subsequent migrations."""
from sqlalchemy import text from sqlalchemy import text
with engine.connect() as conn:
# Add extra_cost to product_ingredients if missing migrations = [
"ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN image_url VARCHAR",
"ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)",
"ALTER TABLE table_groups ADD COLUMN prefix VARCHAR",
"ALTER TABLE table_groups ADD COLUMN color VARCHAR",
"ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER",
"ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT",
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT",
"ALTER TABLE product_options ADD COLUMN sub_choices TEXT",
# Zone-based access control
"""CREATE TABLE IF NOT EXISTS waiter_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
waiter_id INTEGER NOT NULL REFERENCES users(id),
group_id INTEGER REFERENCES table_groups(id),
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Payment tracking on items
"ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)",
"ALTER TABLE order_items ADD COLUMN paid_at DATETIME",
"ALTER TABLE order_items ADD COLUMN payment_method VARCHAR",
# Full audit log
"""CREATE TABLE IF NOT EXISTS order_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
event_type VARCHAR NOT NULL,
waiter_id INTEGER REFERENCES users(id),
item_ids TEXT,
amount REAL,
payment_method VARCHAR,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Waiter profile fields
"ALTER TABLE users ADD COLUMN full_name VARCHAR",
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
# Discounts table (future-proofed, schema ready now)
"""CREATE TABLE IF NOT EXISTS order_discounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
item_id INTEGER REFERENCES order_items(id),
discount_type VARCHAR NOT NULL,
discount_value REAL NOT NULL,
applied_by INTEGER NOT NULL REFERENCES users(id),
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reason TEXT
)""",
]
for sql in migrations:
try: try:
conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0")) with engine.connect() as conn:
conn.commit() conn.execute(text(sql))
except Exception: conn.commit()
pass
# Add image_url to products if missing
try:
conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
conn.commit()
except Exception:
pass
# Add group_id to tables if missing (added in Phase 3 table groups)
try:
conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
conn.commit()
except Exception: except Exception:
pass pass
@@ -66,6 +109,11 @@ IMAGE_DIR = "/app/data/product_images"
os.makedirs(IMAGE_DIR, exist_ok=True) os.makedirs(IMAGE_DIR, exist_ok=True)
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images") app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
# Serve waiter avatars as static files
AVATAR_DIR = "/app/data/avatars"
os.makedirs(AVATAR_DIR, exist_ok=True)
app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars")
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(tables.router, prefix="/api/tables", tags=["tables"]) app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
app.include_router(products.router, prefix="/api/products", tags=["products"]) app.include_router(products.router, prefix="/api/products", tags=["products"])

View File

@@ -21,7 +21,9 @@ class Order(Base):
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed") closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan") waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
print_logs = relationship("PrintLog", back_populates="order") print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
class OrderWaiter(Base): class OrderWaiter(Base):
@@ -52,9 +54,15 @@ class OrderItem(Base):
added_at = Column(DateTime, default=datetime.utcnow) added_at = Column(DateTime, default=datetime.utcnow)
printed = Column(Boolean, default=False, nullable=False) printed = Column(Boolean, default=False, nullable=False)
# Payment tracking
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
paid_at = Column(DateTime, nullable=True)
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
order = relationship("Order", back_populates="items") order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items") product = relationship("Product", back_populates="order_items")
added_by_user = relationship("User", back_populates="order_items") added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
class PrintLog(Base): class PrintLog(Base):
@@ -70,3 +78,44 @@ class PrintLog(Base):
order = relationship("Order", back_populates="print_logs") order = relationship("Order", back_populates="print_logs")
printer = relationship("Printer", back_populates="print_logs") printer = relationship("Printer", back_populates="print_logs")
class OrderAuditLog(Base):
"""Immutable append-only audit trail for every action on an order."""
__tablename__ = "order_audit_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
event_type = Column(String, nullable=False)
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED)
amount = Column(Float, nullable=True) # total value for PAYMENT events
payment_method = Column(String, nullable=True)
note = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
order = relationship("Order", back_populates="audit_logs")
waiter = relationship("User")
@property
def waiter_name(self):
return self.waiter.username if self.waiter else None
class OrderDiscount(Base):
"""Records a discount applied to an order or a specific item."""
__tablename__ = "order_discounts"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
applied_at = Column(DateTime, default=datetime.utcnow)
reason = Column(Text, nullable=True)
order = relationship("Order", back_populates="discounts")
item = relationship("OrderItem")
applied_by_user = relationship("User")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -24,6 +24,7 @@ class Product(Base):
is_available = Column(Boolean, default=True, nullable=False) is_available = Column(Boolean, default=True, nullable=False)
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True) printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
image_url = Column(String, nullable=True) image_url = Column(String, nullable=True)
sort_order = Column(Integer, default=0, nullable=False)
category = relationship("Category", back_populates="products") category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products") printer_zone = relationship("Printer", back_populates="products")
@@ -40,6 +41,8 @@ class ProductOption(Base):
product_id = Column(Integer, ForeignKey("products.id"), nullable=False) product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0) extra_cost = Column(Float, default=0.0)
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
sub_choices = Column(Text, nullable=True)
product = relationship("Product", back_populates="options") product = relationship("Product", back_populates="options")
@@ -61,6 +64,10 @@ class ProductPreferenceSet(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False) product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
default_choice_id = Column(Integer, nullable=True)
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
# Shared sub-set shown for all choices that don't have disables_subset=True
shared_subset = Column(Text, nullable=True)
product = relationship("Product", back_populates="preference_sets") product = relationship("Product", back_populates="preference_sets")
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan") choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
@@ -73,5 +80,10 @@ class ProductPreferenceChoice(Base):
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False) set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0) extra_cost = Column(Float, default=0.0)
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
# Per-choice inline sub-preference shown only when this choice is selected.
sub_choices = Column(Text, nullable=True)
# When True this choice hides the set-level shared_subset on the PWA.
disables_subset = Column(Boolean, default=False, nullable=False)
set = relationship("ProductPreferenceSet", back_populates="choices") set = relationship("ProductPreferenceSet", back_populates="choices")

View File

@@ -8,9 +8,12 @@ class TableGroup(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
prefix = Column(String, nullable=True)
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
color = Column(String, nullable=True)
tables = relationship("Table", back_populates="group") tables = relationship("Table", back_populates="group")
waiter_zones = relationship("WaiterZone", back_populates="group")
class Table(Base): class Table(Base):

View File

@@ -12,12 +12,18 @@ class User(Base):
pin_hash = Column(String, nullable=False) pin_hash = Column(String, nullable=False)
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin' role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
full_name = Column(String, nullable=True)
nickname = Column(String, nullable=True)
mobile_phone = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener") orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer") orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
order_items = relationship("OrderItem", back_populates="added_by_user") order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user")
items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user")
order_assignments = relationship("OrderWaiter", back_populates="waiter") order_assignments = relationship("OrderWaiter", back_populates="waiter")
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
primary_assignments = relationship( primary_assignments = relationship(
"AssistantAssignment", "AssistantAssignment",
@@ -31,6 +37,21 @@ class User(Base):
) )
class WaiterZone(Base):
"""Maps a waiter to a table group they are allowed to operate in.
If a waiter has NO rows here, they see NOTHING.
A sentinel row with group_id=NULL means 'all zones'."""
__tablename__ = "waiter_zones"
id = Column(Integer, primary_key=True, index=True)
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
assigned_at = Column(DateTime, default=datetime.utcnow)
waiter = relationship("User", back_populates="zone_assignments")
group = relationship("TableGroup", back_populates="waiter_zones")
class AssistantAssignment(Base): class AssistantAssignment(Base):
__tablename__ = "assistant_assignments" __tablename__ = "assistant_assignments"

View File

@@ -7,3 +7,4 @@ Pillow==10.4.0
bcrypt==4.2.0 bcrypt==4.2.0
pyjwt==2.9.0 pyjwt==2.9.0
httpx==0.27.2 httpx==0.27.2
python-multipart==0.0.9

View File

@@ -1,49 +1,22 @@
import jwt
import bcrypt import bcrypt
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from config import settings
from models.user import User from models.user import User
from schemas.auth import LoginRequest, TokenResponse from schemas.auth import LoginRequest, TokenResponse
from schemas.user import UserOut from schemas.user import UserOut
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
router = APIRouter() router = APIRouter()
TOKEN_EXPIRY_HOURS = 8
# In-memory token blacklist (cleared on restart — acceptable for local use)
_blacklisted_tokens: set[str] = set()
def _make_token(user: User) -> str:
payload = {
"sub": str(user.id),
"username": user.username,
"role": user.role,
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def decode_token(token: str) -> dict:
if token in _blacklisted_tokens:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)): def login(body: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == body.username, User.is_active == True).first() user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()): if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = _make_token(user) token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user)) return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@@ -53,17 +26,17 @@ def refresh(token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first() user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
_blacklisted_tokens.add(token) blacklist_token(token)
new_token = _make_token(user) new_token = make_token(user)
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user)) return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
@router.post("/logout") @router.post("/logout")
def logout(token: str): def logout(token: str):
_blacklisted_tokens.add(token) blacklist_token(token)
return {"status": "logged out"} return {"status": "logged out"}
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
def me(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def me(user: User = Depends(get_current_user)):
return user return user

View File

@@ -1,13 +1,45 @@
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from config import settings
from models.user import User from models.user import User
from routers.auth import decode_token
bearer = HTTPBearer() bearer = HTTPBearer()
# In-memory token blacklist (cleared on restart — acceptable for local use)
_blacklisted_tokens: set[str] = set()
TOKEN_EXPIRY_HOURS = 8
def make_token(user: User) -> str:
payload = {
"sub": str(user.id),
"username": user.username,
"role": user.role,
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def decode_token(token: str) -> dict:
if token in _blacklisted_tokens:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def blacklist_token(token: str):
_blacklisted_tokens.add(token)
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer), credentials: HTTPAuthorizationCredentials = Depends(bearer),

View File

@@ -5,30 +5,48 @@ from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from database import get_db from database import get_db
from models.order import Order, OrderItem, OrderWaiter from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.user import User, AssistantAssignment from models.user import User, WaiterZone
from models.table import Table
from models.product import Product from models.product import Product
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
from pydantic import BaseModel
class PrintOrderRequest(BaseModel):
printer_id: int
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
router = APIRouter() router = APIRouter()
def _can_access_order(order: Order, user: User, db: Session) -> bool: def _can_access_order(order: Order, user: User, db: Session) -> bool:
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
if user.role in ("manager", "sysadmin"): if user.role in ("manager", "sysadmin"):
return True return True
if order.opened_by == user.id: zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
if not zones:
return False
if any(z.group_id is None for z in zones):
return True return True
if any(ow.waiter_id == user.id for ow in order.waiters): table = db.query(Table).filter(Table.id == order.table_id).first()
return True if not table:
# Assistant check: user is assistant to any waiter assigned to this order return False
assigned_ids = {ow.waiter_id for ow in order.waiters} allowed_group_ids = {z.group_id for z in zones}
assistant_of = db.query(AssistantAssignment).filter( return table.group_id in allowed_group_ids
AssistantAssignment.assistant_waiter_id == user.id,
AssistantAssignment.primary_waiter_id.in_(assigned_ids),
).first() def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
return assistant_of is not None item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
db.add(OrderAuditLog(
order_id=order_id,
event_type=event_type,
waiter_id=waiter_id,
item_ids=json.dumps(item_ids) if item_ids is not None else None,
amount=amount,
payment_method=payment_method,
note=note,
))
@router.get("/", response_model=List[OrderOut]) @router.get("/", response_model=List[OrderOut])
@@ -83,16 +101,16 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
db.add(order) db.add(order)
db.flush() db.flush()
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
return order return order
@router.post("/{order_id}/items", response_model=OrderOut) @router.post("/{order_id}/items", response_model=AddItemsResponse)
def add_items( def add_items(
order_id: int, order_id: int,
body: AddItemsRequest, body: AddItemsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
@@ -109,7 +127,6 @@ def add_items(
product = db.query(Product).filter(Product.id == item_in.product_id).first() product = db.query(Product).filter(Product.id == item_in.product_id).first()
if not product or not product.is_available: if not product or not product.is_available:
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
# Calculate extra cost from selected options
extra_cost = sum( extra_cost = sum(
(o.price_delta or o.extra_cost or 0.0) (o.price_delta or o.extra_cost or 0.0)
for o in (item_in.selected_options or []) for o in (item_in.selected_options or [])
@@ -119,7 +136,7 @@ def add_items(
product_id=item_in.product_id, product_id=item_in.product_id,
added_by=user.id, added_by=user.id,
quantity=item_in.quantity, quantity=item_in.quantity,
unit_price=product.base_price + extra_cost, # price snapshot with options unit_price=product.base_price + extra_cost,
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None, selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
notes=item_in.notes, notes=item_in.notes,
@@ -128,13 +145,13 @@ def add_items(
db.flush() db.flush()
new_item_ids.append(item.id) new_item_ids.append(item.id)
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
# Printer routing runs in background — must never block the order save print_results = route_and_print_sync(order_id, new_item_ids, db)
background_tasks.add_task(route_and_print, order_id, new_item_ids)
return order return {"order": order, "print_results": print_results}
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut) @router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
@@ -155,6 +172,7 @@ def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user
if not item: if not item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
item.status = "cancelled" item.status = "cancelled"
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
db.commit() db.commit()
@@ -171,16 +189,25 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
OrderItem.order_id == order_id, OrderItem.order_id == order_id,
OrderItem.status == "active", OrderItem.status == "active",
).all() ).all()
now = datetime.utcnow()
total_paid = 0.0
for item in items: for item in items:
item.status = "paid" item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_method
total_paid += item.unit_price * item.quantity
active_remaining = db.query(OrderItem).filter( active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active" OrderItem.order_id == order_id, OrderItem.status == "active"
).count() ).count()
order.status = "paid" if active_remaining == 0 else "partially_paid" order.status = "paid" if active_remaining == 0 else "partially_paid"
paid_ids = [i.id for i in items]
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
amount=total_paid, payment_method=body.payment_method)
db.commit() db.commit()
return {"status": order.status, "paid_item_ids": [i.id for i in items]} return {"status": order.status, "paid_item_ids": paid_ids}
@router.post("/{order_id}/close") @router.post("/{order_id}/close")
@@ -195,6 +222,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
order.status = "closed" order.status = "closed"
order.closed_at = datetime.utcnow() order.closed_at = datetime.utcnow()
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit() db.commit()
return {"status": "closed"} return {"status": "closed"}
@@ -207,6 +235,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order.status = "cancelled" order.status = "cancelled"
order.closed_at = datetime.utcnow() order.closed_at = datetime.utcnow()
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit() db.commit()
@@ -234,3 +263,58 @@ def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db),
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
db.delete(assignment) db.delete(assignment)
db.commit() db.commit()
@router.post("/{order_id}/print")
def print_order(
order_id: int,
body: PrintOrderRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.printer import Printer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
table = db.query(Table).filter(Table.id == order.table_id).first()
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
opener = db.query(User).filter(User.id == order.opened_by).first()
waiter_name = opener.username if opener else f"#{order.opened_by}"
items_data = []
for item in order.items:
if item.status == "cancelled":
continue
product_name = item.product.name if item.product else f"#{item.product_id}"
items_data.append({
"name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"total": item.unit_price * item.quantity,
"status": item.status,
})
grand_total = sum(i["total"] for i in items_data)
receipt = {
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status,
"items": items_data,
"total": grand_total,
"notes": order.notes,
}
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
return {"status": "printing"}

View File

@@ -1,14 +1,16 @@
import os import os
import uuid import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from database import get_db from database import get_db
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.order import OrderItem
from models.user import User from models.user import User
from schemas.product import ( from schemas.product import (
ProductCreate, ProductUpdate, ProductOut, ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem, CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate, PreferenceSetCreate,
) )
@@ -24,7 +26,13 @@ def _replace_options(db, product, options):
db.delete(opt) db.delete(opt)
db.flush() db.flush()
for opt in options: for opt in options:
db.add(ProductOption(product_id=product.id, **opt.model_dump())) sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
sub_choices=sub_json,
))
def _replace_ingredients(db, product, ingredients): def _replace_ingredients(db, product, ingredients):
@@ -40,11 +48,29 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
db.delete(ps) db.delete(ps)
db.flush() db.flush()
for ps in sets: for ps in sets:
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name) shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
new_set = ProductPreferenceSet(
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
)
db.add(new_set) db.add(new_set)
db.flush() db.flush()
created_choices = []
for ch in ps.choices: for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump())) sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None
choice = ProductPreferenceChoice(
set_id=new_set.id,
name=ch.name,
extra_cost=ch.extra_cost,
sub_choices=sub_json,
disables_subset=ch.disables_subset,
)
db.add(choice)
db.flush()
created_choices.append(choice)
if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices):
new_set.default_choice_id = created_choices[ps.default_choice_index].id
# ── Categories ──────────────────────────────────────────────────────────────── # ── Categories ────────────────────────────────────────────────────────────────
@@ -101,25 +127,32 @@ def list_products(all: bool = False, db: Session = Depends(get_db), user: User =
q = db.query(Product) q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"): if not all or user.role not in ("manager", "sysadmin"):
q = q.filter(Product.is_available == True) q = q.filter(Product.is_available == True)
return q.all() return q.order_by(Product.sort_order, Product.id).all()
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
for item in items:
product = db.query(Product).filter(Product.id == item.id).first()
if product:
product.sort_order = item.sort_order
db.commit()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"}) data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
product = Product(**data) product = Product(**data)
db.add(product) db.add(product)
db.flush() db.flush()
for opt in body.options: for opt in body.options:
db.add(ProductOption(product_id=product.id, **opt.model_dump())) sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
for ing in body.ingredients: for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump())) db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
for ps in body.preference_sets: _replace_preference_sets(db, product, body.preference_sets)
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
db.add(new_set)
db.flush()
for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
db.commit() db.commit()
db.refresh(product) db.refresh(product)
return product return product
@@ -154,7 +187,6 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
os.makedirs(IMAGE_DIR, exist_ok=True) os.makedirs(IMAGE_DIR, exist_ok=True)
# Delete old image if exists
if product.image_url: if product.image_url:
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url)) old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
if os.path.exists(old_path): if os.path.exists(old_path):
@@ -175,9 +207,18 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
product = db.query(Product).filter(Product.id == product_id).first() product = db.query(Product).filter(Product.id == product_id).first()
if not product: if not product:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
product.is_available = False if hard:
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
if has_orders:
raise HTTPException(
status_code=400,
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
)
db.delete(product)
else:
product.is_available = False
db.commit() db.commit()

View File

@@ -1,49 +1,170 @@
from fastapi import APIRouter, Depends, Query import json
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional, List from typing import Optional, List
from database import get_db from database import get_db
from models.order import Order, OrderItem, OrderWaiter from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.user import User from models.user import User
from models.table import Table from models.table import Table
from models.printer import Printer
from schemas.order import OrderOut from schemas.order import OrderOut
from schemas.table import TableOut from schemas.table import TableOut
from routers.deps import require_manager from routers.deps import require_manager
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
router = APIRouter() router = APIRouter()
@router.get("/shift") @router.get("/shift")
def shift_summary( def shift_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
report_date: Optional[date] = Query(default=None, alias="date"), report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None, waiter_id: Optional[int] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_manager), user: User = Depends(require_manager),
): ):
target = report_date or date.today() """Payments collected per waiter — based on paid_by on order items."""
start = datetime.combine(target, datetime.min.time()) if from_dt and to_dt:
end = start + timedelta(days=1) start = datetime.fromisoformat(from_dt)
end = datetime.fromisoformat(to_dt)
else:
target = report_date or date.today()
start = datetime.combine(target, datetime.min.time())
end = start + timedelta(days=1)
q = db.query(Order).filter(Order.opened_at >= start, Order.opened_at < end) q = db.query(OrderItem).filter(
OrderItem.status == "paid",
OrderItem.paid_at >= start,
OrderItem.paid_at < end,
)
if waiter_id: if waiter_id:
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) q = q.filter(OrderItem.paid_by == waiter_id)
orders = q.all() items = q.all()
summary = {} waiters_db = {u.id: u for u in db.query(User).all()}
for order in orders: tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
waiter = db.query(User).filter(User.id == order.opened_by).first()
key = waiter.username if waiter else "unknown"
if key not in summary:
summary[key] = {"orders": 0, "items": 0, "total": 0.0}
summary[key]["orders"] += 1
for item in order.items:
if item.status in ("active", "paid"):
summary[key]["items"] += item.quantity
summary[key]["total"] += item.unit_price * item.quantity
return {"date": str(target), "waiters": summary} # Build per-waiter summary keyed by waiter_id
summary: dict[int, dict] = {}
for item in items:
wid = item.paid_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[wid]["total"] += val
oid = item.order_id
if oid not in summary[wid]["order_data"]:
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
}
summary[wid]["order_data"][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
summary[wid]["order_data"][oid]["items"].append(
{"name": product_name, "quantity": item.quantity}
)
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/shift/orders")
def shift_orders_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Items sent (added) per waiter — regardless of payment status."""
if from_dt and to_dt:
start = datetime.fromisoformat(from_dt)
end = datetime.fromisoformat(to_dt)
else:
target = report_date or date.today()
start = datetime.combine(target, datetime.min.time())
end = start + timedelta(days=1)
q = db.query(OrderItem).filter(
OrderItem.status.in_(["active", "paid"]),
OrderItem.added_at >= start,
OrderItem.added_at < end,
)
if waiter_id:
q = q.filter(OrderItem.added_by == waiter_id)
items = q.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
summary: dict[int, dict] = {}
for item in items:
wid = item.added_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[wid]["total"] += val
oid = item.order_id
if oid not in summary[wid]["order_data"]:
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
}
summary[wid]["order_data"][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
summary[wid]["order_data"][oid]["items"].append(
{"name": product_name, "quantity": item.quantity}
)
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/orders/history", response_model=List[OrderOut]) @router.get("/orders/history", response_model=List[OrderOut])
@@ -52,6 +173,7 @@ def order_history(
to_date: Optional[str] = Query(default=None, alias="to"), to_date: Optional[str] = Query(default=None, alias="to"),
waiter_id: Optional[int] = None, waiter_id: Optional[int] = None,
order_status: Optional[str] = Query(default=None, alias="status"), order_status: Optional[str] = Query(default=None, alias="status"),
table_id: Optional[int] = None,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -66,6 +188,8 @@ def order_history(
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
if order_status: if order_status:
q = q.filter(Order.status == order_status) q = q.filter(Order.status == order_status)
if table_id:
q = q.filter(Order.table_id == table_id)
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
@@ -84,3 +208,233 @@ def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_m
"order_id": active_order.id if active_order else None, "order_id": active_order.id if active_order else None,
}) })
return result return result
@router.get("/printers")
def printer_totals(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Returns totals per printer based on print_log entries in the date range."""
q = db.query(PrintLog).filter(PrintLog.success == True)
if from_date:
q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_date))
if to_date:
q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_date))
logs = q.all()
printers_db = {p.id: p for p in db.query(Printer).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# summary[pid] — aggregated totals
summary: dict[int, dict] = {}
# order_map[pid][order_id] — per-order detail with items
order_map: dict[int, dict] = {}
for log in logs:
pid = log.printer_id
if pid not in summary:
printer = printers_db.get(pid)
summary[pid] = {
"printer_id": pid,
"printer_name": printer.name if printer else f"Printer #{pid}",
"print_jobs": 0,
"orders": set(),
"items": 0,
"total": 0.0,
}
order_map[pid] = {}
summary[pid]["print_jobs"] += 1
summary[pid]["orders"].add(log.order_id)
oid = log.order_id
if oid not in order_map[pid]:
order = db.query(Order).filter(Order.id == oid).first()
order_map[pid][oid] = {
"order_id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
summary[pid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[pid]["total"] += val
order_map[pid][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
order_map[pid][oid]["items"].append({"name": product_name, "quantity": item.quantity})
result = []
for pid, entry in summary.items():
entry["orders"] = len(entry["orders"])
entry["order_data"] = list(order_map.get(pid, {}).values())
result.append(entry)
return {"printers": result}
class PrintWaiterReportBody(BaseModel):
waiter_name: str
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
class PrintPrinterReportBody(BaseModel):
printer_target_id: int
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
class PrintOrderBody(BaseModel):
printer_id: int
@router.post("/print/waiter")
def print_waiter(
body: PrintWaiterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
# Gather orders for this waiter in time range
waiter = db.query(User).filter(User.username == body.waiter_name).first()
q = db.query(Order).filter(
Order.opened_at >= from_dt,
Order.opened_at <= to_dt,
)
if waiter:
q = q.filter(Order.opened_by == waiter.id)
else:
q = q.filter(False)
orders = q.all()
# Enrich with table names
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
order_data = []
for o in orders:
active_items = [i for i in o.items if i.status in ("active", "paid")]
total = sum(i.unit_price * i.quantity for i in active_items)
order_data.append({
"id": o.id,
"time_open": o.opened_at.strftime("%H:%M"),
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "",
"table": tables.get(o.table_id, f"#{o.table_id}"),
"total": total,
"items": [
{"name": (i.product.name if i.product else f"#{i.product_id}"), "quantity": i.quantity}
for i in active_items
],
})
items_count = sum(
i.quantity for o in orders for i in o.items if i.status in ("active", "paid")
)
grand_total = sum(d["total"] for d in order_data)
report = {
"waiter_name": body.waiter_name,
"orders": len(orders),
"items": items_count,
"total": grand_total,
"order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
}
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
@router.post("/print/printer")
def print_printer_totals(
body: PrintPrinterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
logs = db.query(PrintLog).filter(
PrintLog.printer_id == body.printer_target_id,
PrintLog.success == True,
PrintLog.printed_at >= from_dt,
PrintLog.printed_at <= to_dt,
).all()
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Build per-order entries keyed by order_id; each log may add more items
order_map: dict = {}
items_count = 0
grand_total = 0.0
for log in logs:
oid = log.order_id
if oid not in order_map:
order = db.query(Order).filter(Order.id == oid).first()
if order:
order_map[oid] = {
"id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
grand_total += val
if oid in order_map:
order_map[oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity})
order_data = list(order_map.values())
report = {
"printer_name": target_name,
"print_jobs": len(logs),
"orders": len(order_map),
"items": items_count,
"total": grand_total,
"order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}

View File

@@ -38,6 +38,11 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
} }
@router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).filter(Printer.is_active == True).all()
@router.post("/printers/test") @router.post("/printers/test")
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()

View File

@@ -5,7 +5,7 @@ from typing import List
from database import get_db from database import get_db
from models.table import Table, TableGroup from models.table import Table, TableGroup
from models.order import Order from models.order import Order
from models.user import User from models.user import User, WaiterZone
from schemas.table import ( from schemas.table import (
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
TableGroupCreate, TableGroupUpdate, TableGroupOut, TableGroupCreate, TableGroupUpdate, TableGroupOut,
@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
if db.query(TableGroup).filter(TableGroup.name == body.name).first(): if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists") raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count() sort_order = db.query(TableGroup).count()
group = TableGroup(name=body.name, sort_order=sort_order) group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
db.add(group) db.add(group)
db.commit() db.commit()
db.refresh(group) db.refresh(group)
@@ -59,17 +59,49 @@ def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depe
# ── Tables ──────────────────────────────────────────────────────────────────── # ── Tables ────────────────────────────────────────────────────────────────────
def _next_global_number(db: Session) -> int:
last = db.query(Table).order_by(Table.number.desc()).first()
return (last.number + 1) if last else 1
@router.get("/", response_model=List[TableOut]) @router.get("/", response_model=List[TableOut])
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Table) q = db.query(Table)
if not include_inactive: if not include_inactive:
q = q.filter(Table.is_active == True) q = q.filter(Table.is_active == True)
return q.order_by(Table.group_id, Table.number).all()
# Zone-based filtering for waiters
if user.role not in ("manager", "sysadmin"):
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
# No zone rows → sees nothing
if not zones:
return []
# Any row with group_id=None → sees all tables (all-zones sentinel)
has_all_zones = any(z.group_id is None for z in zones)
if not has_all_zones:
allowed_group_ids = [z.group_id for z in zones]
q = q.filter(Table.group_id.in_(allowed_group_ids))
tables = q.order_by(Table.group_id, Table.number).all()
active_table_ids = {
row[0] for row in db.query(Order.table_id).filter(
Order.status.in_(["open", "partially_paid"])
).all()
}
result = []
for t in tables:
out = TableOut.model_validate(t)
out.has_active_order = t.id in active_table_ids
result.append(out)
return result
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
table = Table(**body.model_dump()) number = _next_global_number(db)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
db.add(table) db.add(table)
db.commit() db.commit()
db.refresh(table) db.refresh(table)
@@ -80,12 +112,30 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
if body.count < 1 or body.count > 200: if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200") raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
# Group-local label numbering: find highest suffix already used in this group
existing_in_group = (
db.query(Table)
.filter(Table.group_id == body.group_id)
.all()
) if body.group_id else []
# Extract trailing integers from existing labels that start with this prefix
used = []
for t in existing_in_group:
if t.label and t.label.startswith(body.name_prefix):
suffix = t.label[len(body.name_prefix):]
if suffix.isdigit():
used.append(int(suffix))
start_label_n = (max(used) + 1) if used else 1
created = [] created = []
for i in range(body.count): for i in range(body.count):
n = body.start_number + i label_n = start_label_n + i
global_number = _next_global_number(db)
table = Table( table = Table(
number=n, number=global_number,
label=f"{body.name_prefix}{n}", label=f"{body.name_prefix}{label_n}",
group_id=body.group_id, group_id=body.group_id,
is_active=True, is_active=True,
) )
@@ -115,13 +165,22 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
table = db.query(Table).filter(Table.id == table_id).first() table = db.query(Table).filter(Table.id == table_id).first()
if not table: if not table:
raise HTTPException(status_code=404, detail="Table not found") raise HTTPException(status_code=404, detail="Table not found")
active_order = db.query(Order).filter(
Order.table_id == table_id,
Order.status.in_(["open", "partially_paid"])
).first()
if active_order:
raise HTTPException(
status_code=400,
detail="Cannot delete or deactivate a table with an active order"
)
if hard: if hard:
active_order = db.query(Order).filter( # Delete all past (non-active) orders for this table so FK constraint doesn't block deletion.
Order.table_id == table_id, # Active orders are already blocked above. Items/waiters/print_logs cascade via ORM.
Order.status.in_(["open", "partially_paid"]) past_orders = db.query(Order).filter(Order.table_id == table_id).all()
).first() for order in past_orders:
if active_order: db.delete(order)
raise HTTPException(status_code=400, detail="Cannot delete table with active order") db.flush()
db.delete(table) db.delete(table)
else: else:
table.is_active = False table.is_active = False

View File

@@ -1,20 +1,30 @@
import os
import uuid
import bcrypt import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from database import get_db from database import get_db
from models.user import User, AssistantAssignment from models.user import User, AssistantAssignment, WaiterZone
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
from routers.deps import require_manager from routers.deps import require_manager
router = APIRouter() router = APIRouter()
AVATAR_DIR = "/app/data/avatars"
class ResetPinRequest:
def __init__(self, pin: str):
self.pin = pin
# ── Helpers ───────────────────────────────────────────────────────────────────
def _waiter_or_404(waiter_id: int, db: Session) -> User:
w = db.query(User).filter(User.id == waiter_id).first()
if not w:
raise HTTPException(status_code=404, detail="Waiter not found")
return w
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[UserOut]) @router.get("/", response_model=List[UserOut])
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)): def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
@@ -35,9 +45,7 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
@router.put("/{waiter_id}", response_model=UserOut) @router.put("/{waiter_id}", response_model=UserOut)
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = db.query(User).filter(User.id == waiter_id).first() waiter = _waiter_or_404(waiter_id, db)
if not waiter:
raise HTTPException(status_code=404, detail="Waiter not found")
for field, value in body.model_dump(exclude_none=True).items(): for field, value in body.model_dump(exclude_none=True).items():
setattr(waiter, field, value) setattr(waiter, field, value)
db.commit() db.commit()
@@ -47,9 +55,7 @@ def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db
@router.put("/{waiter_id}/reset-pin") @router.put("/{waiter_id}/reset-pin")
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)): def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = db.query(User).filter(User.id == waiter_id).first() waiter = _waiter_or_404(waiter_id, db)
if not waiter:
raise HTTPException(status_code=404, detail="Waiter not found")
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
db.commit() db.commit()
return {"status": "pin reset"} return {"status": "pin reset"}
@@ -57,9 +63,7 @@ def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: Use
@router.put("/{waiter_id}/block") @router.put("/{waiter_id}/block")
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = db.query(User).filter(User.id == waiter_id).first() waiter = _waiter_or_404(waiter_id, db)
if not waiter:
raise HTTPException(status_code=404, detail="Waiter not found")
waiter.is_active = not waiter.is_active waiter.is_active = not waiter.is_active
db.commit() db.commit()
return {"is_active": waiter.is_active} return {"is_active": waiter.is_active}
@@ -67,13 +71,79 @@ def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Dep
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = db.query(User).filter(User.id == waiter_id).first() waiter = _waiter_or_404(waiter_id, db)
if not waiter:
raise HTTPException(status_code=404, detail="Waiter not found")
db.delete(waiter) db.delete(waiter)
db.commit() db.commit()
# ── Avatar upload / delete ───────────────────────────────────────────────────
@router.post("/{waiter_id}/avatar", response_model=UserOut)
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Delete old avatar file if present
if waiter.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
dest = os.path.join(AVATAR_DIR, filename)
os.makedirs(AVATAR_DIR, exist_ok=True)
content = await file.read()
with open(dest, "wb") as f:
f.write(content)
waiter.avatar_url = f"/static/avatars/{filename}"
db.commit()
db.refresh(waiter)
return waiter
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
if waiter.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
waiter.avatar_url = None
db.commit()
db.refresh(waiter)
return waiter
# ── Zone assignments ──────────────────────────────────────────────────────────
@router.put("/{waiter_id}/zones")
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Replace all zone assignments for a waiter atomically.
- all_zones=True → single NULL group_id row (sees everything)
- group_ids=[1,2] → rows for groups 1 and 2 only
- group_ids=[] → no rows at all (sees nothing)
"""
_waiter_or_404(waiter_id, db)
# Wipe existing assignments
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
if body.all_zones:
db.add(WaiterZone(waiter_id=waiter_id, group_id=None))
elif body.group_ids:
for gid in body.group_ids:
db.add(WaiterZone(waiter_id=waiter_id, group_id=gid))
db.commit()
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all()
return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]}
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut) @router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
existing = db.query(AssistantAssignment).filter( existing = db.query(AssistantAssignment).filter(

View File

@@ -42,6 +42,22 @@ class OrderItemOut(BaseModel):
status: str status: str
added_at: datetime added_at: datetime
printed: bool printed: bool
paid_by: Optional[int] = None
paid_at: Optional[datetime] = None
payment_method: Optional[str] = None
model_config = {"from_attributes": True}
class PrintResultOut(BaseModel):
printer_name: str
success: bool
error: Optional[str] = None
class AddItemsResponse(BaseModel):
order: "OrderOut"
print_results: List[PrintResultOut]
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -52,6 +68,7 @@ class OrderCreate(BaseModel):
class PayItemsRequest(BaseModel): class PayItemsRequest(BaseModel):
item_ids: List[int] item_ids: List[int]
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
class AssignWaiterRequest(BaseModel): class AssignWaiterRequest(BaseModel):
@@ -63,6 +80,21 @@ class OrderWaiterOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class AuditLogOut(BaseModel):
id: int
order_id: int
event_type: str
waiter_id: Optional[int] = None
waiter_name: Optional[str] = None # resolved server-side
item_ids: Optional[str] = None
amount: Optional[float] = None
payment_method: Optional[str] = None
note: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class OrderOut(BaseModel): class OrderOut(BaseModel):
id: int id: int
table_id: int table_id: int
@@ -74,5 +106,6 @@ class OrderOut(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
items: List[OrderItemOut] = [] items: List[OrderItemOut] = []
waiters: List[OrderWaiterOut] = [] waiters: List[OrderWaiterOut] = []
audit_logs: List[AuditLogOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel import json
from typing import Optional, List from pydantic import BaseModel, model_validator, field_validator
from typing import Optional, List, Any
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
@@ -30,21 +31,43 @@ class CategoryReorderItem(BaseModel):
# ── Options ────────────────────────────────────────────────────────────────── # ── Options ──────────────────────────────────────────────────────────────────
class OptionSubChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
class ProductOptionBase(BaseModel): class ProductOptionBase(BaseModel):
name: str name: str
extra_cost: float = 0.0 extra_cost: float = 0.0
class ProductOptionCreate(ProductOptionBase): class ProductOptionCreate(ProductOptionBase):
pass sub_choices: List[OptionSubChoice] = []
class ProductOptionOut(ProductOptionBase): class ProductOptionOut(ProductOptionBase):
id: int id: int
product_id: int product_id: int
sub_choices: List[OptionSubChoice] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_option_sub_choices(cls, data: Any) -> Any:
if hasattr(data, 'sub_choices'):
raw = data.sub_choices
parsed = json.loads(raw) if isinstance(raw, str) else []
return {
'id': data.id,
'product_id': data.product_id,
'name': data.name,
'extra_cost': data.extra_cost,
'sub_choices': parsed,
}
return data
# ── Ingredients ─────────────────────────────────────────────────────────────── # ── Ingredients ───────────────────────────────────────────────────────────────
@@ -64,39 +87,108 @@ class ProductIngredientOut(ProductIngredientBase):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Preferences ─────────────────────────────────────────────────────────────── # ── Sub-choices (nested under a preference choice) ────────────────────────────
class PreferenceChoiceBase(BaseModel): class SubChoice(BaseModel):
name: str name: str
extra_cost: float = 0.0 extra_cost: float = 0.0
is_default: bool = False
class PreferenceChoiceCreate(PreferenceChoiceBase): # ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
pass
class SharedSubsetChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
class PreferenceChoiceOut(PreferenceChoiceBase): class SharedSubset(BaseModel):
name: str
choices: List[SharedSubsetChoice] = []
# ── Preferences ───────────────────────────────────────────────────────────────
class PreferenceChoiceCreate(BaseModel):
name: str
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
class PreferenceChoiceOut(BaseModel):
id: int id: int
set_id: int set_id: int
name: str
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_sub_choices(cls, data: Any) -> Any:
if hasattr(data, 'sub_choices'):
raw = data.sub_choices
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except Exception:
parsed = []
else:
parsed = []
return {
'id': data.id,
'set_id': data.set_id,
'name': data.name,
'extra_cost': data.extra_cost,
'sub_choices': parsed,
'disables_subset': data.disables_subset or False,
}
return data
class PreferenceSetBase(BaseModel):
class PreferenceSetCreate(BaseModel):
name: str name: str
class PreferenceSetCreate(PreferenceSetBase):
choices: List[PreferenceChoiceCreate] = [] choices: List[PreferenceChoiceCreate] = []
default_choice_index: Optional[int] = None # index into choices (0-based)
shared_subset: Optional[SharedSubset] = None
class PreferenceSetOut(PreferenceSetBase): class PreferenceSetOut(BaseModel):
id: int id: int
product_id: int product_id: int
name: str
choices: List[PreferenceChoiceOut] = [] choices: List[PreferenceChoiceOut] = []
default_choice_id: Optional[int] = None
shared_subset: Optional[SharedSubset] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_shared_subset(cls, data: Any) -> Any:
if hasattr(data, 'shared_subset'):
raw = data.shared_subset
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except Exception:
parsed = None
else:
parsed = None
return {
'id': data.id,
'product_id': data.product_id,
'name': data.name,
'choices': list(data.choices),
'default_choice_id': data.default_choice_id,
'shared_subset': parsed,
}
return data
# ── Products ────────────────────────────────────────────────────────────────── # ── Products ──────────────────────────────────────────────────────────────────
@@ -106,6 +198,7 @@ class ProductBase(BaseModel):
base_price: float base_price: float
is_available: bool = True is_available: bool = True
printer_zone_id: Optional[int] = None printer_zone_id: Optional[int] = None
sort_order: int = 0
class ProductCreate(ProductBase): class ProductCreate(ProductBase):
@@ -120,11 +213,17 @@ class ProductUpdate(BaseModel):
base_price: Optional[float] = None base_price: Optional[float] = None
is_available: Optional[bool] = None is_available: Optional[bool] = None
printer_zone_id: Optional[int] = None printer_zone_id: Optional[int] = None
sort_order: Optional[int] = None
options: Optional[List[ProductOptionCreate]] = None options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = None preference_sets: Optional[List[PreferenceSetCreate]] = None
class ProductReorderItem(BaseModel):
id: int
sort_order: int
class ProductOut(ProductBase): class ProductOut(ProductBase):
id: int id: int
options: List[ProductOptionOut] = [] options: List[ProductOptionOut] = []

View File

@@ -4,40 +4,45 @@ from typing import Optional, List
class TableGroupCreate(BaseModel): class TableGroupCreate(BaseModel):
name: str name: str
prefix: Optional[str] = None
color: Optional[str] = None
class TableGroupUpdate(BaseModel): class TableGroupUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
prefix: Optional[str] = None
color: Optional[str] = None
class TableGroupOut(BaseModel): class TableGroupOut(BaseModel):
id: int id: int
name: str name: str
prefix: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
color: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class TableBase(BaseModel): class TableBase(BaseModel):
number: int
label: Optional[str] = None label: Optional[str] = None
group_id: Optional[int] = None group_id: Optional[int] = None
is_active: bool = True is_active: bool = True
class TableCreate(TableBase): class TableCreate(BaseModel):
pass label: Optional[str] = None
group_id: Optional[int] = None
class TableBatchCreate(BaseModel): class TableBatchCreate(BaseModel):
group_id: Optional[int] = None group_id: Optional[int] = None
count: int count: int
name_prefix: str # e.g. "Out-" → Out-1, Out-2 ... name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ...
start_number: int = 1 # start_number is computed on the backend from existing tables in the group
class TableUpdate(BaseModel): class TableUpdate(BaseModel):
number: Optional[int] = None
label: Optional[str] = None label: Optional[str] = None
group_id: Optional[int] = None group_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@@ -48,10 +53,15 @@ class TableFloorplanUpdate(BaseModel):
floor_y: float floor_y: float
class TableOut(TableBase): class TableOut(BaseModel):
id: int id: int
number: int
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
floor_x: Optional[float] = None floor_x: Optional[float] = None
floor_y: Optional[float] = None floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None group: Optional[TableGroupOut] = None
has_active_order: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -1,12 +1,16 @@
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
class UserBase(BaseModel): class UserBase(BaseModel):
username: str username: str
role: str role: str
is_active: bool = True is_active: bool = True
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
avatar_url: Optional[str] = None
class UserCreate(UserBase): class UserCreate(UserBase):
@@ -17,15 +21,35 @@ class UserUpdate(BaseModel):
username: Optional[str] = None username: Optional[str] = None
role: Optional[str] = None role: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
class WaiterZoneOut(BaseModel):
id: int
waiter_id: int
group_id: Optional[int] = None # None = all zones
model_config = {"from_attributes": True}
class UserOut(UserBase): class UserOut(UserBase):
id: int id: int
created_at: datetime created_at: datetime
zone_assignments: List[WaiterZoneOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class SetZonesRequest(BaseModel):
"""Replace all zone assignments for a waiter in one call.
group_ids=[] means remove all (sees nothing).
group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel."""
group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel
all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row
class AssistantAssignmentOut(BaseModel): class AssistantAssignmentOut(BaseModel):
id: int id: int
primary_waiter_id: int primary_waiter_id: int

View File

@@ -160,6 +160,173 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
p.cut() p.cut()
# ── On-demand report / receipt prints ────────────────────────────────────────
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΣΕΡΒΙΤΟΡΟΥ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σερβιτορος: {report['waiter_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Build right-aligned total: "HH:MM - HH:MM - TABLE . . . 9.99e"
time_open = od.get("time_open", "")
time_close = od.get("time_close", "")
table = od["table"]
value = f"{od['total']:.2f}e"
times_part = f"{time_open} - {time_close}" if time_close else time_open
prefix = f"{times_part} - {table}"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
line = f"{prefix}{dots}{value}"
_raw_text(p, line + "\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_waiter_report failed for %s:%s%s", ip, port, e)
def print_printer_report(ip: str, port: int, report: dict, mode: str):
"""Print a per-printer totals report. mode='simple'|'extensive'."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Header line: "HH:MM - TABLE . . . . 9.99e"
prefix = f"{od['time']} - {od['table']}"
value = f"{od['total']:.2f}e"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
header_line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
header_line = f"{prefix}{dots}{value}"
p._raw(b'\x1b\x45\x01')
_raw_text(p, header_line + "\n")
p._raw(b'\x1b\x45\x00')
# Indented items
for item in od.get("items", []):
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_printer_report failed for %s:%s%s", ip, port, e)
def print_order_receipt(ip: str, port: int, receipt: dict):
"""Print a manager-triggered order receipt."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Τραπεζι: {receipt['table_name']}\n")
_raw_text(p, f"Σερβιτορος: {receipt['waiter_name']}\n")
_raw_text(p, f"Ανοιχτηκε: {receipt['opened_at']}\n")
if receipt.get("closed_at"):
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
for item in receipt.get("items", []):
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
if receipt.get("notes"):
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σημ: {receipt['notes']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {receipt['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_order_receipt failed for %s:%s%s", ip, port, e)
# ── Routing logic ──────────────────────────────────────────────────────────── # ── Routing logic ────────────────────────────────────────────────────────────
def route_and_print(order_id: int, item_ids: List[int]): def route_and_print(order_id: int, item_ids: List[int]):
@@ -169,58 +336,76 @@ def route_and_print(order_id: int, item_ids: List[int]):
""" """
db: Session = SessionLocal() db: Session = SessionLocal()
try: try:
order = db.query(Order).filter(Order.id == order_id).first() _do_route_and_print(order_id, item_ids, db)
if not order:
logger.error("route_and_print: order %s not found", order_id)
return
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
# Group items by printer zone
zone_map: dict[int, List[OrderItem]] = {}
unzoned: List[OrderItem] = []
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product and product.printer_zone_id:
zone_map.setdefault(product.printer_zone_id, []).append(item)
else:
unzoned.append(item)
if unzoned:
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
for printer_id, zone_items in zone_map.items():
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
if not printer:
logger.warning("Printer %s not found or inactive", printer_id)
continue
success = False
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
p.close()
success = True
# Mark items as printed
for item in zone_items:
item.printed = True
db.commit()
except Exception as e:
error_msg = str(e)
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
log = PrintLog(
order_id=order_id,
printer_id=printer_id,
item_ids=json.dumps([i.id for i in zone_items]),
success=success,
error_message=error_msg,
)
db.add(log)
db.commit()
except Exception as e: except Exception as e:
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e) logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
finally: finally:
db.close() db.close()
def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
"""
Synchronous variant used when the caller needs print results.
Returns a list of per-printer result dicts:
{ printer_name, success, error }
"""
return _do_route_and_print(order_id, item_ids, db)
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
results = []
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
logger.error("route_and_print: order %s not found", order_id)
return results
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
# Group items by printer zone
zone_map: dict[int, List[OrderItem]] = {}
unzoned: List[OrderItem] = []
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product and product.printer_zone_id:
zone_map.setdefault(product.printer_zone_id, []).append(item)
else:
unzoned.append(item)
if unzoned:
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
for printer_id, zone_items in zone_map.items():
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
if not printer:
logger.warning("Printer %s not found or inactive", printer_id)
results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"})
continue
success = False
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
p.close()
success = True
for item in zone_items:
item.printed = True
db.commit()
except Exception as e:
error_msg = str(e)
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
log = PrintLog(
order_id=order_id,
printer_id=printer_id,
item_ids=json.dumps([i.id for i in zone_items]),
success=success,
error_message=error_msg,
)
db.add(log)
db.commit()
results.append({"printer_name": printer.name, "success": success, "error": error_msg})
return results

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS Manager</title> <title>POS Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,79 @@
/**
* DateInput / DateTimeInput
*
* Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US).
* These wrappers overlay the native input with a visible DD/MM/YYYY display
* while keeping the full native picker UX (click, keyboard, mobile wheel).
*
* Props mirror a plain <input>: value (YYYY-MM-DD or YYYY-MM-DDTHH:MM),
* onChange (receives the same synthetic event), className.
*/
import { useRef } from 'react'
function formatDateGR(value) {
// value is "YYYY-MM-DD"
if (!value) return ''
const [y, m, d] = value.split('-')
if (!y || !m || !d) return value
return `${d}/${m}/${y}`
}
function formatDateTimeGR(value) {
// value is "YYYY-MM-DDTHH:MM"
if (!value) return ''
const [datePart, timePart] = value.split('T')
if (!datePart) return value
const [y, m, d] = datePart.split('-')
if (!y || !m || !d) return value
return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}`
}
export function DateInput({ value, onChange, className = '', ...rest }) {
const ref = useRef(null)
return (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
{/* Visible display */}
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
</div>
{/* Native input — invisible but functional (provides the picker) */}
<input
ref={ref}
type="date"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
const ref = useRef(null)
return (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
</div>
<input
ref={ref}
type="datetime-local"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}

View File

@@ -2,24 +2,232 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import client from '../api/client' import client from '../api/client'
import StatusBadge from '../components/StatusBadge'
const API_URL = import.meta.env.VITE_API_URL || ''
const FILTERS = ['all', 'open', 'partially_paid', 'free'] const FILTERS = ['all', 'open', 'partially_paid', 'free']
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' } const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
function elapsed(openedAt) { // ─── Design tokens ────────────────────────────────────────────────────────────
const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) const COLORS = {
if (diff < 60) return `${diff}λ` open: {
return `${Math.floor(diff / 60)}ω ${diff % 60}λ` label: 'Ανοιχτό',
tint: '#eef7f0', tintStrong: '#d7ecdc',
accent: '#2f9e5e', ink: '#1f7042',
},
partially_paid: {
label: 'Μερική πληρ.',
tint: '#f4eefb', tintStrong: '#e3d4f3',
accent: '#7a44c9', ink: '#57309a',
},
free: {
label: 'Ελεύθερο',
tint: '#f4f4f2', tintStrong: '#dfe2e6',
accent: '#8a9099', ink: '#5a6169',
},
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatEuro(n) {
return '€' + parseFloat(n).toFixed(2)
}
function formatDuration(openedAt) {
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
if (mins < 60) return `${mins}m`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
function occupiedMinsFromDate(openedAt) {
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
} }
function orderTotal(items = []) { function orderTotal(items = []) {
return items return items
.filter(i => i.status !== 'cancelled') .filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0) .reduce((s, i) => s + i.unit_price * i.quantity, 0)
.toFixed(2)
} }
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterBubble({ waiter, size = 26 }) {
// waiter: { name, avatarUrl }
if (waiter.avatarUrl) {
return (
<img
src={waiter.avatarUrl}
alt={waiter.name}
style={{
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── V1 Table Card ────────────────────────────────────────────────────────────
function TableCardV1({ name, status, amount, openedAt, waiters = [], onClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
const showMulti = waiters.length >= 3
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
flexShrink: 0,
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: '14px 0 0 14px',
}} />
{/* Header: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: '#111315',
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row — fixed height placeholder */}
<div style={{ marginTop: 8, height: 22 }} />
{/* Stats row */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
</div>
</button>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function DashboardPage() { export default function DashboardPage() {
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const navigate = useNavigate() const navigate = useNavigate()
@@ -27,13 +235,13 @@ export default function DashboardPage() {
const { data: tables = [], isLoading: tablesLoading } = useQuery({ const { data: tables = [], isLoading: tablesLoading } = useQuery({
queryKey: ['tables'], queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data), queryFn: () => client.get('/api/tables/').then(r => r.data),
refetchInterval: 30_000, refetchInterval: 5_000,
}) })
const { data: orders = [], isLoading: ordersLoading } = useQuery({ const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['orders-active'], queryKey: ['orders-active'],
queryFn: () => client.get('/api/orders/').then(r => r.data), queryFn: () => client.get('/api/orders/').then(r => r.data),
refetchInterval: 30_000, refetchInterval: 5_000,
}) })
const { data: waiters = [] } = useQuery({ const { data: waiters = [] } = useQuery({
@@ -42,9 +250,14 @@ export default function DashboardPage() {
staleTime: 60_000, staleTime: 60_000,
}) })
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username])) // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
const waiterMap = Object.fromEntries(waiters.map(w => {
const name = w.full_name || w.nickname || w.username
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
return [w.id, { name, shortName, avatarUrl }]
}))
// Build enriched table list
const tableCards = tables.map(table => { const tableCards = tables.map(table => {
const order = orders.find(o => const order = orders.find(o =>
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status) o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
@@ -82,35 +295,25 @@ export default function DashboardPage() {
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p> <p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)} )}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{filtered.map(({ table, order, tableStatus }) => ( {filtered.map(({ table, order, tableStatus }) => {
<button const waiterNames = order
key={table.id} ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
onClick={() => order && navigate(`/orders/${order.id}`)} : []
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`} const amount = order ? orderTotal(order.items) : null
>
<div className="flex items-start justify-between mb-3">
<span className="text-2xl font-extrabold text-gray-800">
{table.label || `T${table.number}`}
</span>
<StatusBadge status={tableStatus} />
</div>
{order ? ( return (
<div className="space-y-1 text-sm text-gray-600"> <TableCardV1
<p className="font-semibold text-gray-800">{orderTotal(order.items)}</p> key={table.id}
<p> {elapsed(order.opened_at)}</p> name={table.label || `T${table.number}`}
{order.waiters.length > 0 && ( status={tableStatus}
<p className="text-xs text-gray-500 truncate"> amount={amount}
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')} openedAt={order?.opened_at ?? null}
</p> waiters={waiterNames}
)} onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
</div> />
) : ( )
<p className="text-sm text-gray-400 mt-1"></p> })}
)}
</button>
))}
</div> </div>
</div> </div>
) )

View File

@@ -6,6 +6,36 @@ import client from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
function submit() {
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
onPrint(Number(printerId))
onClose()
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div>
<label className="label">Εκτυπωτής</label>
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
</div>
</div>
</div>
)
}
function itemTotal(item) { function itemTotal(item) {
return (item.unit_price * item.quantity).toFixed(2) return (item.unit_price * item.quantity).toFixed(2)
} }
@@ -15,13 +45,58 @@ function formatDate(dt) {
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' }) return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
} }
const EVENT_LABELS = {
ORDER_OPENED: 'Άνοιγμα',
ITEMS_ADDED: 'Προσθήκη',
PAYMENT: 'Πληρωμή',
ORDER_CLOSED: 'Κλείσιμο',
ORDER_CANCELLED: 'Ακύρωση',
ITEM_CANCELLED: 'Ακύρωση αντ.',
}
function AuditTab({ order, waiterMap }) {
if (!order.audit_logs || order.audit_logs.length === 0) {
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
}
return (
<div className="divide-y divide-gray-100">
{order.audit_logs.map(log => (
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
<div className="shrink-0 mt-0.5">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
'bg-blue-100 text-blue-700'
}`}>
{EVENT_LABELS[log.event_type] ?? log.event_type}
</span>
</div>
<div className="flex-1 min-w-0 text-sm text-gray-700">
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
{log.amount != null && (
<span className="ml-2 font-semibold text-green-700">{log.amount.toFixed(2)}</span>
)}
{log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
)}
</div>
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
</div>
))}
</div>
)
}
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) { export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
const { orderId: paramOrderId } = useParams() const { orderId: paramOrderId } = useParams()
const orderId = propOrderId ?? paramOrderId const orderId = propOrderId ?? paramOrderId
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const [tab, setTab] = useState('overview')
const [confirmAction, setConfirmAction] = useState(null) // { type, payload } const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
const [showPrintModal, setShowPrintModal] = useState(false)
const { data: order, isLoading } = useQuery({ const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId], queryKey: ['order', orderId],
@@ -35,6 +110,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
staleTime: 60_000, staleTime: 60_000,
}) })
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 60_000,
})
const printOrder = useMutation({
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username])) const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id)) const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
@@ -119,81 +206,100 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div> </div>
</div> </div>
{/* Waiters */} {/* Tabs */}
<div className="card p-4"> <div className="flex gap-1 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2> {[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<div className="flex flex-wrap gap-2"> <button
{order.waiters.map(w => ( key={key}
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1"> onClick={() => setTab(key)}
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span> className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
{isOpen && !readOnly && ( >
<button {label}
onClick={() => removeWaiter.mutate(w.waiter_id)} </button>
className="text-gray-400 hover:text-red-500 text-xs leading-none"
>
</button>
)}
</div>
))}
{isOpen && !readOnly && (
<select
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
defaultValue=""
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
<option value="">+ Πρόσθεσε</option>
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
<option key={w.id} value={w.id}>{w.username}</option>
))}
</select>
)}
</div>
</div>
{/* Items */}
<div className="card divide-y divide-gray-100">
<div className="px-4 py-3">
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
</div>
{order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)}
{order.items.map(item => (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={item.status} />
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
))} ))}
</div> </div>
{/* Actions */} {tab === 'overview' && <>
{isOpen && !readOnly && ( {/* Waiters */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
<div className="flex flex-wrap gap-2">
{order.waiters.map(w => (
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
{isOpen && !readOnly && (
<button
onClick={() => removeWaiter.mutate(w.waiter_id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none"
>
</button>
)}
</div>
))}
{isOpen && !readOnly && (
<select
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
defaultValue=""
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
<option value="">+ Πρόσθεσε</option>
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
<option key={w.id} value={w.id}>{w.username}</option>
))}
</select>
)}
</div>
</div>
{/* Items */}
<div className="card divide-y divide-gray-100">
<div className="px-4 py-3">
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
</div>
{order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)}
{order.items.map(item => (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
{item.paid_by && (
<p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={item.status} />
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
))}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{activeItems.length > 0 && ( {isOpen && !readOnly && activeItems.length > 0 && (
<button <button
onClick={() => payItems.mutate(activeItems.map(i => i.id))} onClick={() => payItems.mutate(activeItems.map(i => i.id))}
className="btn btn-primary" className="btn btn-primary"
@@ -201,19 +307,35 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
Πληρωμή όλων Πληρωμή όλων
</button> </button>
)} )}
{isOpen && !readOnly && (
<>
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
className="btn btn-secondary"
>
Κλείσιμο παραγγελίας
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
className="btn btn-danger"
>
Ακύρωση παραγγελίας
</button>
</>
)}
<button <button
onClick={() => setConfirmAction({ type: 'closeOrder' })} onClick={() => setShowPrintModal(true)}
className="btn btn-secondary" className="btn btn-secondary"
> >
Κλείσιμο παραγγελίας 🖨 Εκτύπωση
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
className="btn btn-danger"
>
Ακύρωση παραγγελίας
</button> </button>
</div> </div>
</>}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />
</div>
)} )}
{confirmAction && ( {confirmAction && (
@@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
onCancel={() => setConfirmAction(null)} onCancel={() => setConfirmAction(null)}
/> />
)} )}
{showPrintModal && printers.length > 0 && (
<PrintOrderModal
printers={printers}
onClose={() => setShowPrintModal(false)}
onPrint={(printerId) => printOrder.mutate(printerId)}
/>
)}
{showPrintModal && printers.length === 0 && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
</div>
</div>
)}
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,40 @@ import toast from 'react-hot-toast'
import client from '../api/client' import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ZoneColorPicker({ value, onChange }) {
return (
<div className="flex flex-wrap gap-2 mt-1">
<button
type="button"
onClick={() => onChange(null)}
className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
style={{ borderColor: !value ? '#000' : 'transparent' }}
title="Χωρίς χρώμα"
/>
{ZONE_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
/>
))}
</div>
)
}
export default function TablesPage() { export default function TablesPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [addModal, setAddModal] = useState(false) const [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null) const [editModal, setEditModal] = useState(null)
const [batchModal, setBatchModal] = useState(null) // group id or null const [batchModal, setBatchModal] = useState(null) // group object or null
const [groupModal, setGroupModal] = useState(null) // null | {} | group object const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null) // { id, hard } const [confirmDelete, setConfirmDelete] = useState(null)
const [showInactive, setShowInactive] = useState(false) const [showInactive, setShowInactive] = useState(false)
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const { data: tables = [], isLoading } = useQuery({ const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive], queryKey: ['tables-all', showInactive],
@@ -29,15 +55,6 @@ export default function TablesPage() {
} }
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] }) const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
// Next auto-increment number within a group (or global)
function nextNumber(groupId) {
const relevant = groupId
? tables.filter(t => t.group_id === groupId)
: tables
if (relevant.length === 0) return 1
return Math.max(...relevant.map(t => t.number)) + 1
}
const createTable = useMutation({ const createTable = useMutation({
mutationFn: (body) => client.post('/api/tables/', body), mutationFn: (body) => client.post('/api/tables/', body),
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() }, onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
@@ -70,81 +87,125 @@ export default function TablesPage() {
mutationFn: (body) => groupModal?.id mutationFn: (body) => groupModal?.id
? client.put(`/api/tables/groups/${groupModal.id}`, body) ? client.put(`/api/tables/groups/${groupModal.id}`, body)
: client.post('/api/tables/groups', body), : client.post('/api/tables/groups', body),
onSuccess: () => { toast.success('Γκρουπ αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() }, onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'), onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
}) })
const deleteGroup = useMutation({ const deleteGroup = useMutation({
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`), mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
onSuccess: () => { toast.success('Γκρουπ διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() }, onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
// Group tables by group // Filter tables for the active tab
const grouped = [ const visibleTables = activeTab === 'all'
{ group: null, tables: tables.filter(t => !t.group_id) }, ? tables
...groups.map(g => ({ group: g, tables: tables.filter(t => t.group_id === g.id) })), : activeTab === 'ungrouped'
].filter(section => section.tables.length > 0 || section.group) ? tables.filter(t => !t.group_id)
: tables.filter(t => t.group_id === activeTab)
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div> if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1> <h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap items-center">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" /> <input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
Εμφάνιση ανενεργών Εμφάνιση ανενεργών
</label> </label>
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέο γκρουπ</button> <button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη</button>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button> <button onClick={() => setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι</button>
</div> </div>
</div> </div>
{grouped.map(({ group, tables: gt }) => ( {/* Zone tabs */}
<div key={group?.id ?? 'ungrouped'}> <div className="flex gap-1 flex-wrap border-b border-gray-200 pb-0">
{group && ( {[
<div className="flex items-center gap-3 mb-2"> { id: 'all', label: 'Όλα', color: null },
<h2 className="font-semibold text-gray-700">{group.name}</h2> ...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} ${g.name}` : g.name, color: g.color, group: g })),
<button onClick={() => setGroupModal(group)} className="text-xs text-gray-400 hover:text-gray-600"></button> ...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
<button onClick={() => setBatchModal(group.id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button> ].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
activeTab === tab.id
? 'bg-white border border-b-white border-gray-200 -mb-px text-primary-700'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{tab.color && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: tab.color }} />}
{tab.label}
<span className="ml-0.5 text-xs text-gray-400">
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
</span>
</button>
))}
</div>
{/* Zone header (when viewing a specific zone) */}
{activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
const g = groups.find(g => g.id === activeTab)
if (!g) return null
return (
<div className="flex items-center gap-3">
<div>
<span className="font-semibold text-gray-700">{g.name}</span>
{g.prefix && <span className="ml-2 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
</div> </div>
)} <button onClick={() => setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης</button>
{!group && gt.length > 0 && <h2 className="font-semibold text-gray-500 mb-2 text-sm">Χωρίς γκρουπ</h2>} <button onClick={() => setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
<div className="card divide-y divide-gray-100">
{gt.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-400 text-center">Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.</p>
)}
{gt.map(t => (
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-40' : ''}`}>
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
{!t.is_active && <span className="text-xs text-red-400 font-medium">Ανενεργό</span>}
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{t.is_active
? <button onClick={() => setConfirmDelete({ id: t.id, hard: false })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-amber-600 hover:bg-amber-50">Απενεργ.</button>
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
}
<button onClick={() => setConfirmDelete({ id: t.id, hard: true })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
</div>
))}
</div> </div>
</div> )
))} })()}
{tables.length === 0 && ( {/* Tables list */}
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν τραπέζια. Προσθέστε ένα.</p> <div className="card divide-y divide-gray-100">
)} {visibleTables.length === 0 && (
<p className="px-4 py-8 text-sm text-gray-400 text-center">
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
</p>
)}
{visibleTables.map((t, idx) => (
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-50 bg-gray-50' : ''}`}>
<span className="text-xs text-gray-400 font-mono w-6 text-right">{idx + 1}</span>
<p className="flex-1 font-medium text-gray-800">{t.label || `Τραπέζι ${t.number}`}</p>
{t.group && (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded hidden sm:inline">
{t.group.prefix ? `${t.group.prefix}` : t.group.name}
</span>
)}
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία</button>
{t.is_active
? <button
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: false })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</button>
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
}
<button
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-8 disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</button>
</div>
))}
</div>
{/* Add single table */} {/* Add single table */}
{addModal && ( {addModal && (
<TableModal <TableModal
title="Νέο τραπέζι" title="Νέο τραπέζι"
initial={{ number: nextNumber(null), label: '', group_id: '' }} initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
groups={groups} groups={groups}
onSave={(f) => createTable.mutate({ number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setAddModal(false)} onClose={() => setAddModal(false)}
/> />
)} )}
@@ -153,9 +214,9 @@ export default function TablesPage() {
{editModal && ( {editModal && (
<TableModal <TableModal
title="Επεξεργασία τραπεζιού" title="Επεξεργασία τραπεζιού"
initial={{ number: editModal.number, label: editModal.label || '', group_id: editModal.group_id || '' }} initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
groups={groups} groups={groups}
onSave={(f) => updateTable.mutate({ id: editModal.id, number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setEditModal(null)} onClose={() => setEditModal(null)}
/> />
)} )}
@@ -163,18 +224,17 @@ export default function TablesPage() {
{/* Batch add */} {/* Batch add */}
{batchModal !== null && ( {batchModal !== null && (
<BatchModal <BatchModal
groupId={batchModal} group={batchModal}
startNumber={nextNumber(batchModal)}
onSave={(body) => batchCreate.mutate(body)} onSave={(body) => batchCreate.mutate(body)}
onClose={() => setBatchModal(null)} onClose={() => setBatchModal(null)}
/> />
)} )}
{/* Group form */} {/* Group/Zone form */}
{groupModal !== null && ( {groupModal !== null && (
<GroupModal <GroupModal
group={groupModal} group={groupModal}
onSave={(name) => saveGroup.mutate({ name })} onSave={(data) => saveGroup.mutate(data)}
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null} onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
onClose={() => setGroupModal(null)} onClose={() => setGroupModal(null)}
/> />
@@ -204,53 +264,63 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">{title}</h2> <h2 className="font-bold text-gray-800">{title}</h2>
<div> <div>
<label className="label">Αριθμός τραπεζιού *</label> <label className="label">Όνομα τραπεζιού</label>
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus /> <input
className="input"
placeholder="π.χ. BS-TBL-1 ή Βεράντα 3"
value={form.label}
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
autoFocus
/>
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό για αυτόματη αρίθμηση.</p>
</div> </div>
<div> <div>
<label className="label">Ετικέτα</label> <label className="label">Ζώνη</label>
<input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
</div>
<div>
<label className="label">Γκρουπ</label>
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}> <select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
<option value=""> Χωρίς γκρουπ </option> <option value=""> Χωρίς ζώνη </option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)} {groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
</select> </select>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(form)} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button> <button onClick={() => onSave(form)} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
function BatchModal({ groupId, startNumber, onSave, onClose }) { function BatchModal({ group, onSave, onClose }) {
const [count, setCount] = useState(5) const [count, setCount] = useState(5)
const [prefix, setPrefix] = useState('') const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2> <h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2>
{group && <p className="text-sm text-gray-500">Ζώνη: <span className="font-medium text-gray-700">{group.name}</span></p>}
<div>
<label className="label">Πρόθεμα ονόματος</label>
<input
className="input"
placeholder="π.χ. BS-TBL- → BS-TBL-1, BS-TBL-2…"
value={prefix}
onChange={e => setPrefix(e.target.value)}
autoFocus
/>
<p className="text-xs text-gray-400 mt-1">Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.</p>
</div>
<div> <div>
<label className="label">Πλήθος</label> <label className="label">Πλήθος</label>
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} autoFocus /> <input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} />
</div> </div>
<div>
<label className="label">Πρόθεμα ετικέτας</label>
<input className="input" placeholder="π.χ. Out- → Out-1, Out-2…" value={prefix} onChange={e => setPrefix(e.target.value)} />
</div>
<p className="text-xs text-gray-400">Ξεκινά από αριθμό {startNumber}, δημιουργεί {count} τραπέζια.</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button <button
onClick={() => onSave({ group_id: groupId, count, name_prefix: prefix, start_number: startNumber })} onClick={() => onSave({ group_id: group?.id ?? null, count, name_prefix: prefix })}
disabled={count < 1 || !prefix.trim()} disabled={count < 1 || !prefix.trim()}
className="flex-1 btn btn-primary" className="flex-1 btn btn-primary"
> >
Δημιουργία Δημιουργία {count > 0 && prefix.trim() ? `(${prefix.trim()}1 … ${prefix.trim()}${count})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -260,18 +330,29 @@ function BatchModal({ groupId, startNumber, onSave, onClose }) {
function GroupModal({ group, onSave, onDelete, onClose }) { function GroupModal({ group, onSave, onDelete, onClose }) {
const [name, setName] = useState(group.name || '') const [name, setName] = useState(group.name || '')
const [prefix, setPrefix] = useState(group.prefix || '')
const [color, setColor] = useState(group.color || null)
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία γκρουπ' : 'Νέο γκρουπ'}</h2> <h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}</h2>
<div> <div>
<label className="label">Όνομα γκρουπ</label> <label className="label">Όνομα ζώνης *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus /> <input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
</div>
<div>
<label className="label">Πρόθεμα (για μαζική δημιουργία)</label>
<input className="input font-mono" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="π.χ. BS" />
<p className="text-xs text-gray-400 mt-1">Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.</p>
</div>
<div>
<label className="label">Χρώμα ζώνης</label>
<ZoneColorPicker value={color} onChange={setColor} />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">🗑</button>} {onDelete && <button onClick={onDelete} className="btn btn-danger px-3">Διαγραφή</button>}
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(name)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button> <button onClick={() => onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,42 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import client from '../api/client' import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
const API_URL = import.meta.env.VITE_API_URL || ''
function avatarColor(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return palette[h % palette.length]
}
function WaiterAvatar({ waiter, size = 40 }) {
const displayName = waiter.full_name || waiter.nickname || waiter.username
if (waiter.avatar_url) {
return (
<img
src={API_URL + waiter.avatar_url}
alt={displayName}
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
/>
)
}
const parts = displayName.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(displayName),
color: 'white', fontSize: size * 0.38, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>{initials}</div>
)
}
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫'] const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
function PinInput({ value, onChange }) { function PinInput({ value, onChange }) {
@@ -30,27 +63,152 @@ function PinInput({ value, onChange }) {
) )
} }
function ZoneModal({ waiter, groups, onClose }) {
const qc = useQueryClient()
// Derive initial state from waiter's zone_assignments
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
const [allZones, setAllZones] = useState(hasAllZones)
const [selected, setSelected] = useState(new Set(assignedIds))
const saveZones = useMutation({
mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body),
onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() },
onError: () => toast.error('Σφάλμα'),
})
function toggleGroup(gid) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(gid)) next.delete(gid); else next.add(gid)
return next
})
}
function save() {
if (allZones) {
saveZones.mutate({ all_zones: true, group_ids: [] })
} else {
saveZones.mutate({ all_zones: false, group_ids: [...selected] })
}
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Ζώνες {waiter.username}</h2>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={allZones}
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
/>
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
</label>
{!allZones && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{groups.length === 0 && (
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
)}
{groups.map(g => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={selected.has(g.id)}
onChange={() => toggleGroup(g.id)}
/>
<span className="text-gray-700">{g.name}</span>
{g.color && (
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
)}
</label>
))}
</div>
)}
{!allZones && selected.size === 0 && (
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
</p>
)}
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
Αποθήκευση
</button>
</div>
</div>
</div>
)
}
export default function WaitersPage() { export default function WaitersPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [addModal, setAddModal] = useState(false) const [addModal, setAddModal] = useState(false)
const [pinModal, setPinModal] = useState(null) // waiter id const [pinModal, setPinModal] = useState(null) // waiter id
const [zoneModal, setZoneModal] = useState(null) // waiter object
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
const [newPin, setNewPin] = useState('') const [newPin, setNewPin] = useState('')
const [newForm, setNewForm] = useState({ username: '', pin: '', role: 'waiter' }) const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' })
const [editModal, setEditModal] = useState(null) // waiter object
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '' })
const avatarInputRef = useRef(null)
const { data: waiters = [], isLoading } = useQuery({ const { data: waiters = [], isLoading } = useQuery({
queryKey: ['waiters'], queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data), queryFn: () => client.get('/api/waiters/').then(r => r.data),
}) })
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
})
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] }) const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
const createWaiter = useMutation({ const createWaiter = useMutation({
mutationFn: (body) => client.post('/api/waiters/', body), mutationFn: (body) => client.post('/api/waiters/', body),
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', pin: '', role: 'waiter' }); invalidate() }, onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'), onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
}) })
const updateWaiter = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body),
onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const uploadAvatar = useMutation({
mutationFn: ({ id, file }) => {
const fd = new FormData()
fd.append('file', file)
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
},
onSuccess: (res) => {
toast.success('Avatar ανέβηκε')
setEditModal(res.data)
invalidate()
},
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
})
const deleteAvatar = useMutation({
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
onSuccess: (res) => {
toast.success('Avatar αφαιρέθηκε')
setEditModal(res.data)
invalidate()
},
onError: () => toast.error('Σφάλμα'),
})
const toggleBlock = useMutation({ const toggleBlock = useMutation({
mutationFn: (id) => client.put(`/api/waiters/${id}/block`), mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
onSuccess: () => { invalidate() }, onSuccess: () => { invalidate() },
@@ -72,7 +230,7 @@ export default function WaitersPage() {
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div> if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return ( return (
<div className="space-y-4 max-w-3xl"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1> <h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button> <button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
@@ -84,13 +242,31 @@ export default function WaitersPage() {
)} )}
{waiters.map(w => ( {waiters.map(w => (
<div key={w.id} className="flex items-center gap-4 px-4 py-3"> <div key={w.id} className="flex items-center gap-4 px-4 py-3">
<WaiterAvatar waiter={w} size={44} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800">{w.username}</p> <div className="flex items-baseline gap-2">
<p className="text-xs text-gray-500">{w.role}</p> <p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
</div>
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
{w.role === 'waiter' && (
<p className="text-xs text-gray-400 mt-0.5">
{w.zone_assignments.length === 0
? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null)
? 'Όλες οι ζώνες'
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
</p>
)}
</div> </div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}> <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'} {w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
</span> </span>
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{w.role === 'waiter' && (
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
)}
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button> <button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}> <button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'} {w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
@@ -105,9 +281,17 @@ export default function WaitersPage() {
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2> <h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
<div>
<label className="label">Πλήρες όνομα</label>
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div> <div>
<label className="label">Όνομα χρήστη</label> <label className="label">Όνομα χρήστη</label>
<input className="input" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus /> <input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div> </div>
<div> <div>
<label className="label">Ρόλος</label> <label className="label">Ρόλος</label>
@@ -123,7 +307,7 @@ export default function WaitersPage() {
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button> <button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button <button
onClick={() => createWaiter.mutate({ username: newForm.username, pin: newForm.pin, role: newForm.role, is_active: true })} onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
disabled={!newForm.username.trim() || newForm.pin.length < 4} disabled={!newForm.username.trim() || newForm.pin.length < 4}
className="flex-1 btn btn-primary" className="flex-1 btn btn-primary"
> >
@@ -134,6 +318,76 @@ export default function WaitersPage() {
</div> </div>
)} )}
{/* Edit profile modal */}
{editModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Επεξεργασία {editModal.username}</h2>
{/* Avatar section */}
<div className="flex items-center gap-4">
<WaiterAvatar waiter={editModal} size={64} />
<div className="flex flex-col gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) uploadAvatar.mutate({ id: editModal.id, file })
e.target.value = ''
}}
/>
<button
onClick={() => avatarInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
>
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
</button>
{editModal.avatar_url && (
<button
onClick={() => deleteAvatar.mutate(editModal.id)}
disabled={deleteAvatar.isPending}
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
>
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Όνομα χρήστη</label>
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Πλήρες όνομα</label>
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
</div>
<div>
<label className="label">Παρατσούκλι (nickname)</label>
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null })}
disabled={updateWaiter.isPending || !editForm.username.trim()}
className="flex-1 btn btn-primary"
>
Αποθήκευση
</button>
</div>
</div>
</div>
)}
{/* Reset PIN modal */} {/* Reset PIN modal */}
{pinModal !== null && ( {pinModal !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
@@ -164,6 +418,10 @@ export default function WaitersPage() {
onCancel={() => setConfirmDelete(null)} onCancel={() => setConfirmDelete(null)}
/> />
)} )}
{zoneModal && (
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
)}
</div> </div>
) )
} }

View File

@@ -10,40 +10,164 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const ingredients = product.ingredients || [] const ingredients = product.ingredients || []
const preferenceSets = product.preference_sets || [] const preferenceSets = product.preference_sets || []
const [selectedPreferences, setSelectedPreferences] = useState( // selectedPreferences: { [setId]: choice | null }
Object.fromEntries(preferenceSets.map(ps => [ps.id, null])) const [selectedPreferences, setSelectedPreferences] = useState(() =>
Object.fromEntries(
preferenceSets.map(ps => {
const def = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
return [ps.id, def]
})
)
) )
// Per-preference-choice inline sub-choices: { [choiceId]: subChoice | null }
const [selectedSubChoices, setSelectedSubChoices] = useState(() => {
const init = {}
preferenceSets.forEach(ps => {
const def = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
if (def && def.sub_choices?.length > 0) {
const subDef = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
init[def.id] = subDef
}
})
return init
})
// Shared-subset selections: { [setId]: subChoice | null }
const [selectedSharedSubs, setSelectedSharedSubs] = useState(() => {
const init = {}
preferenceSets.forEach(ps => {
if (ps.shared_subset?.choices?.length > 0) {
const selectedChoice = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
if (!selectedChoice || !selectedChoice.disables_subset) {
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
init[ps.id] = subDef
}
}
})
return init
})
// Option sub-choices: { [optionId]: subChoice | null }
// Initialise with any option that has a default sub-choice pre-selected — but only
// if the option itself is checked by default (options are all unchecked initially).
const [selectedOptionSubs, setSelectedOptionSubs] = useState({})
function selectPreference(setId, choice) { function selectPreference(setId, choice) {
setSelectedPreferences(prev => ({ ...prev, [setId]: choice })) setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
if (choice && choice.sub_choices?.length > 0) {
const subDef = choice.sub_choices.find(s => s.is_default) ?? choice.sub_choices[0]
setSelectedSubChoices(prev => ({ ...prev, [choice.id]: subDef }))
}
const ps = preferenceSets.find(p => p.id === setId)
if (ps?.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
setSelectedSharedSubs(prev => {
if (prev[setId] != null) return prev
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
return { ...prev, [setId]: subDef }
})
}
}
function selectSubChoice(parentChoiceId, sub) {
setSelectedSubChoices(prev => ({ ...prev, [parentChoiceId]: sub }))
}
function selectSharedSub(setId, sub) {
setSelectedSharedSubs(prev => ({ ...prev, [setId]: sub }))
} }
function toggleOption(opt) { function toggleOption(opt) {
setSelectedOptions(prev => { setSelectedOptions(prev => {
const exists = prev.find(o => o.id === opt.id) const exists = prev.find(o => o.id === opt.id)
if (exists) return prev.filter(o => o.id !== opt.id) if (exists) {
// Deselecting: also clear its sub-choice selection
setSelectedOptionSubs(s => { const n = { ...s }; delete n[opt.id]; return n })
return prev.filter(o => o.id !== opt.id)
}
// Selecting: pre-select default sub-choice if any
if (opt.sub_choices?.length > 0) {
const subDef = opt.sub_choices.find(s => s.is_default) ?? opt.sub_choices[0]
setSelectedOptionSubs(s => ({ ...s, [opt.id]: subDef }))
}
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }] return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
}) })
} }
function selectOptionSub(optId, sub) {
setSelectedOptionSubs(prev => ({ ...prev, [optId]: sub }))
}
function toggleIngredient(ing) { function toggleIngredient(ing) {
setRemovedIngredients(prev => setRemovedIngredients(prev =>
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name] prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
) )
} }
const prefExtra = Object.values(selectedPreferences).reduce((s, ch) => s + (ch?.extra_cost ?? 0), 0) // Check whether any checked option with sub_choices is missing its sub-choice selection
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta ?? o.extra_cost ?? 0), 0) + prefExtra const optionSubsMissing = selectedOptions.some(o => {
const totalPrice = (product.base_price + extraPrice) * quantity const full = options.find(opt => opt.id === o.id)
return full?.sub_choices?.length > 0 && selectedOptionSubs[o.id] == null
})
function isPrefSetComplete(ps) {
const choice = selectedPreferences[ps.id]
if (choice == null) return false
if (choice.sub_choices?.length > 0 && selectedSubChoices[choice.id] == null) return false
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && selectedSharedSubs[ps.id] == null) return false
return true
}
const allPrefsSelected = preferenceSets.every(isPrefSetComplete)
const unselectedPrefs = preferenceSets.filter(ps => !isPrefSetComplete(ps))
const canAdd = allPrefsSelected && !optionSubsMissing
const prefExtra = preferenceSets.reduce((s, ps) => {
const choice = selectedPreferences[ps.id]
if (!choice) return s
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset)
? (selectedSharedSubs[ps.id] ?? null) : null
return s + (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
}, 0)
const optionExtra = selectedOptions.reduce((s, o) => {
const subExtra = selectedOptionSubs[o.id]?.extra_cost ?? 0
return s + (o.price_delta ?? 0) + subExtra
}, 0)
const totalPrice = (product.base_price + optionExtra + prefExtra) * quantity
function handleAdd() { function handleAdd() {
const prefChoices = Object.values(selectedPreferences) if (!canAdd) return
.filter(Boolean) const prefChoices = preferenceSets.flatMap(ps => {
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 })) const choice = selectedPreferences[ps.id]
if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = selectedSharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
}
return entries
})
const optionEntries = selectedOptions.flatMap(o => {
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }]
const sub = selectedOptionSubs[o.id]
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
return entries
})
onAdd({ onAdd({
product_id: product.id, product_id: product.id,
quantity, quantity,
selected_options: [...selectedOptions, ...prefChoices], selected_options: [...optionEntries, ...prefChoices],
removed_ingredients: removedIngredients, removed_ingredients: removedIngredients,
notes, notes,
}) })
@@ -57,51 +181,138 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
<h2 className="modal-title">{product.name}</h2> <h2 className="modal-title">{product.name}</h2>
<p className="modal-price">{Number(totalPrice).toFixed(2)} </p> <p className="modal-price">{Number(totalPrice).toFixed(2)} </p>
{/* ── Checkbox options with optional sub-choices ── */}
{options.length > 0 && ( {options.length > 0 && (
<section className="modal-section"> <section className="modal-section">
<h3>Επιλογές</h3> <h3>Επιλογές</h3>
{options.map(opt => ( {options.map(opt => {
<label key={opt.id} className="modal-option"> const isChecked = !!selectedOptions.find(o => o.id === opt.id)
<input const hasSubs = opt.sub_choices?.length > 0
type="checkbox" const subMissing = isChecked && hasSubs && selectedOptionSubs[opt.id] == null
checked={!!selectedOptions.find(o => o.id === opt.id)} return (
onChange={() => toggleOption(opt)} <div key={opt.id}>
/> <label className="modal-option">
<span>{opt.name}</span> <input type="checkbox" checked={isChecked} onChange={() => toggleOption(opt)} />
{(opt.extra_cost ?? 0) !== 0 && <span className="option-price">{(opt.extra_cost ?? 0) > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} </span>} <span>{opt.name}</span>
</label> {(opt.extra_cost ?? 0) !== 0 && (
))} <span className="option-price">{opt.extra_cost > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} </span>
)}
</label>
{isChecked && hasSubs && (
<div style={{
marginLeft: 24, marginTop: 4, marginBottom: 6,
padding: '8px 12px', borderRadius: 8,
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
}}>
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}> απαιτείται επιλογή</p>}
{opt.sub_choices.map((sub, si) => (
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
<input type="radio" name={`optsub-${opt.id}`}
checked={selectedOptionSubs[opt.id]?.name === sub.name}
onChange={() => selectOptionSub(opt.id, sub)} />
<span>{sub.name}</span>
{(sub.extra_cost ?? 0) !== 0 && (
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} </span>
)}
</label>
))}
</div>
)}
</div>
)
})}
</section> </section>
)} )}
{preferenceSets.map(ps => ( {/* ── Preference sets ── */}
<section key={ps.id} className="modal-section"> {preferenceSets.map(ps => {
<h3>{ps.name}</h3> const missing = !isPrefSetComplete(ps)
{ps.choices.map(ch => ( const selectedChoice = selectedPreferences[ps.id] ?? null
<label key={ch.id} className="modal-option"> const showSharedSubset = ps.shared_subset?.choices?.length > 0
<input && selectedChoice != null
type="radio" && !selectedChoice.disables_subset
name={`pref-${ps.id}`} const sharedMissing = showSharedSubset && selectedSharedSubs[ps.id] == null
checked={selectedPreferences[ps.id]?.id === ch.id}
onChange={() => selectPreference(ps.id, ch)}
/>
<span>{ch.name}</span>
{(ch.extra_cost ?? 0) !== 0 && <span className="option-price">{(ch.extra_cost ?? 0) > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} </span>}
</label>
))}
</section>
))}
return (
<section key={ps.id} className="modal-section"
style={missing ? { border: '1.5px solid #ef4444', borderRadius: 10, padding: '10px 12px' } : {}}>
<h3 style={{ color: missing ? '#ef4444' : undefined }}>
{ps.name}
{missing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}> απαιτείται επιλογή</span>}
</h3>
{ps.choices.map(ch => {
const isSelected = selectedPreferences[ps.id]?.id === ch.id
const hasSubs = ch.sub_choices?.length > 0
const subMissing = isSelected && hasSubs && selectedSubChoices[ch.id] == null
return (
<div key={ch.id}>
<label className="modal-option">
<input type="radio" name={`pref-${ps.id}`} checked={isSelected}
onChange={() => selectPreference(ps.id, ch)} />
<span>{ch.name}</span>
{(ch.extra_cost ?? 0) !== 0 && (
<span className="option-price">{ch.extra_cost > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} </span>
)}
</label>
{isSelected && hasSubs && (
<div style={{
marginLeft: 24, marginTop: 4, marginBottom: 6,
padding: '8px 12px', borderRadius: 8,
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
}}>
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}> απαιτείται επιλογή</p>}
{ch.sub_choices.map((sub, si) => (
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
<input type="radio" name={`sub-${ch.id}`}
checked={selectedSubChoices[ch.id]?.name === sub.name}
onChange={() => selectSubChoice(ch.id, sub)} />
<span>{sub.name}</span>
{(sub.extra_cost ?? 0) !== 0 && (
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} </span>
)}
</label>
))}
</div>
)}
</div>
)
})}
{showSharedSubset && (
<div style={{
marginTop: 8, marginLeft: 8, padding: '8px 12px', borderRadius: 8,
borderLeft: `3px solid ${sharedMissing ? '#ef4444' : '#6366f1'}`,
}}>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: sharedMissing ? '#ef4444' : '#4338ca' }}>
{ps.shared_subset.name}
{sharedMissing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}> απαιτείται επιλογή</span>}
</p>
{ps.shared_subset.choices.map((sub, si) => (
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
<input type="radio" name={`shared-${ps.id}`}
checked={selectedSharedSubs[ps.id]?.name === sub.name}
onChange={() => selectSharedSub(ps.id, sub)} />
<span>{sub.name}</span>
{(sub.extra_cost ?? 0) !== 0 && (
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} </span>
)}
</label>
))}
</div>
)}
</section>
)
})}
{/* ── Remove ingredients ── */}
{ingredients.length > 0 && ( {ingredients.length > 0 && (
<section className="modal-section"> <section className="modal-section">
<h3>Αφαίρεση υλικών</h3> <h3>Αφαίρεση υλικών</h3>
{ingredients.map(ing => ( {ingredients.map(ing => (
<label key={ing.id} className="modal-option modal-option--remove"> <label key={ing.id} className="modal-option modal-option--remove">
<input <input type="checkbox" checked={removedIngredients.includes(ing.name)}
type="checkbox" onChange={() => toggleIngredient(ing)} />
checked={removedIngredients.includes(ing.name)}
onChange={() => toggleIngredient(ing)}
/>
<span>χωρίς {ing.name}</span> <span>χωρίς {ing.name}</span>
</label> </label>
))} ))}
@@ -110,13 +321,8 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
<section className="modal-section"> <section className="modal-section">
<h3>Σημείωση</h3> <h3>Σημείωση</h3>
<textarea <textarea className="modal-notes" placeholder="π.χ. χωρίς αλάτι..."
className="modal-notes" value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
placeholder="π.χ. χωρίς αλάτι..."
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
/>
</section> </section>
<div className="modal-qty"> <div className="modal-qty">
@@ -125,7 +331,19 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button> <button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
</div> </div>
<button className="btn btn--primary btn--lg" onClick={handleAdd} style={{ width: '100%', marginTop: 16 }}> {!allPrefsSelected && (
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 8 }}>
Επιλέξτε: {unselectedPrefs.map(ps => ps.name).join(', ')}
</p>
)}
{optionSubsMissing && (
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 4 }}>
Επιλέξτε υπο-επιλογή για τις επιλεγμένες επιλογές
</p>
)}
<button className="btn btn--primary btn--lg" onClick={handleAdd} disabled={!canAdd}
style={{ width: '100%', marginTop: 16, opacity: canAdd ? 1 : 0.45 }}>
Προσθήκη στην παραγγελία Προσθήκη στην παραγγελία
</button> </button>
</div> </div>

View File

@@ -4,7 +4,10 @@ export default function PinPad({ onSubmit, loading }) {
const [pin, setPin] = useState('') const [pin, setPin] = useState('')
function press(digit) { function press(digit) {
if (pin.length < 8) setPin(p => p + digit) if (pin.length >= 4) return
const next = pin + digit
setPin(next)
if (next.length === 4 && !loading) onSubmit(next)
} }
function backspace() { function backspace() {
@@ -15,7 +18,7 @@ export default function PinPad({ onSubmit, loading }) {
if (pin.length > 0 && !loading) onSubmit(pin) if (pin.length > 0 && !loading) onSubmit(pin)
} }
const dots = Array.from({ length: 8 }, (_, i) => ( const dots = Array.from({ length: 4 }, (_, i) => (
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}></span> <span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}></span>
)) ))

View File

@@ -1,24 +1,58 @@
import { useState } from 'react' import { useState } from 'react'
import ItemOptionsModal from './ItemOptionsModal' import ItemOptionsModal from './ItemOptionsModal'
function hexToRgba(hex, alpha) {
if (!hex) return null
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
export default function ProductPicker({ categories, products, onAdd }) { export default function ProductPicker({ categories, products, onAdd }) {
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null) const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
const [selectedProduct, setSelectedProduct] = useState(null) const [selectedProduct, setSelectedProduct] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
const filtered = products.filter(p => p.category_id === activeCat) const filtered = products.filter(p => p.category_id === activeCat)
function selectCategory(id) {
setActiveCat(id)
setViewAllOpen(false)
}
return ( return (
<div className="product-picker"> <div className="product-picker">
<div className="category-tabs"> <div className="category-tabs">
{categories.map(cat => ( {/* View All button — always first */}
<button <button
key={cat.id} className="cat-tab cat-tab--viewall"
className={`cat-tab ${activeCat === cat.id ? 'cat-tab--active' : ''}`} onClick={() => setViewAllOpen(true)}
onClick={() => setActiveCat(cat.id)} title="Εμφάνιση όλων"
> >
{cat.name}
</button> </button>
))}
{categories.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
: isActive ? 'var(--accent)' : 'var(--bg3)'
const color = cat.color
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
: isActive ? '#1c1400' : 'var(--muted)'
return (
<button
key={cat.id}
className="cat-tab"
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
onClick={() => setActiveCat(cat.id)}
>
{cat.name}
</button>
)
})}
</div> </div>
<div className="product-grid"> <div className="product-grid">
@@ -35,6 +69,39 @@ export default function ProductPicker({ categories, products, onAdd }) {
)} )}
</div> </div>
{/* View All modal */}
{viewAllOpen && (
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
<div
className="cat-all-modal"
onClick={e => e.stopPropagation()}
>
<div className="cat-all-modal__header">
<span className="cat-all-modal__title">Κατηγορίες</span>
<button className="icon-btn" onClick={() => setViewAllOpen(false)}></button>
</div>
<div className="cat-all-grid">
{categories.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color || 'var(--bg3)'
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
return (
<button
key={cat.id}
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
onClick={() => selectCategory(cat.id)}
>
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
<span className="cat-all-tile__name">{cat.name}</span>
</button>
)
})}
</div>
</div>
</div>
)}
{selectedProduct && ( {selectedProduct && (
<ItemOptionsModal <ItemOptionsModal
product={selectedProduct} product={selectedProduct}

View File

@@ -13,10 +13,11 @@ export default function TableCard({ table, order, currentUserId, onClick }) {
cardClass = 'table-card table-card--active' cardClass = 'table-card table-card--active'
} }
const displayName = table.label || `T${table.number}`
return ( return (
<button className={cardClass} onClick={onClick}> <button className={cardClass} onClick={onClick}>
<span className="table-card__number">{table.number}</span> <span className="table-card__number">{displayName}</span>
{table.name && <span className="table-card__name">{table.name}</span>}
<span className="table-card__status">{statusLabel}</span> <span className="table-card__status">{statusLabel}</span>
</button> </button>
) )

View File

@@ -169,7 +169,7 @@ body { background: var(--bg); }
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
flex: 1; align-content: start;
} }
.table-card { .table-card {
display: flex; display: flex;
@@ -177,7 +177,8 @@ body { background: var(--bg); }
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
min-height: 110px; min-height: 132px;
max-height: 132px;
border-radius: 14px; border-radius: 14px;
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; cursor: pointer;
@@ -239,15 +240,82 @@ body { background: var(--bg); }
flex-shrink: 0; flex-shrink: 0;
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px; border-radius: 20px;
border: none; border: 2px solid transparent;
background: var(--bg3); background: var(--bg3);
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: filter 0.12s;
} }
.cat-tab--active { background: var(--accent); color: #1c1400; } .cat-tab--active { background: var(--accent); color: #1c1400; }
.cat-tab--viewall {
background: var(--bg3);
color: var(--text);
font-size: 18px;
padding: 4px 12px;
border-radius: 10px;
}
/* ── Category All Modal ──────────────────────────────────── */
.cat-all-modal {
position: fixed;
inset: 20px;
background: var(--bg2);
border-radius: 20px;
display: flex;
flex-direction: column;
z-index: 200;
overflow: hidden;
}
.cat-all-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.cat-all-modal__title {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.cat-all-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
padding: 16px;
overflow-y: auto;
flex: 1;
}
.cat-all-tile {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
border-radius: 14px;
border: none;
cursor: pointer;
overflow: hidden;
padding: 8px;
}
.cat-all-tile--active { outline: 3px solid #fff; }
.cat-all-tile__overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.cat-all-tile__name {
position: relative;
font-size: 14px;
font-weight: 700;
color: #fff;
text-align: center;
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
line-height: 1.3;
}
/* ── Product Grid ────────────────────────────────────────── */ /* ── Product Grid ────────────────────────────────────────── */
.product-picker { display: flex; flex-direction: column; flex: 1; } .product-picker { display: flex; flex-direction: column; flex: 1; }

View File

@@ -13,6 +13,8 @@ export default function AddItemsPage() {
const [orderId, setOrderId] = useState(null) const [orderId, setOrderId] = useState(null)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
// null = not yet sent, { allOk, results } = sent
const [printAck, setPrintAck] = useState(null)
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -40,11 +42,19 @@ export default function AddItemsPage() {
if (cart.length === 0 || !orderId) return if (cart.length === 0 || !orderId) return
setSending(true) setSending(true)
setError('') setError('')
setPrintAck(null)
try { try {
await client.post(`/api/orders/${orderId}/items`, { const res = await client.post(`/api/orders/${orderId}/items`, {
items: cart.map(({ _key, ...item }) => item), items: cart.map(({ _key, ...item }) => item),
}) })
navigate(`/tables/${tableId}`) const printResults = res.data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
setPrintAck({ allOk, results: printResults })
if (allOk) {
// All printed fine — navigate back after a short moment
setTimeout(() => navigate(`/tables/${tableId}`), 1200)
}
// If there were print failures, stay on page — waiter sees the ack panel
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε') setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
} finally { } finally {
@@ -56,6 +66,76 @@ export default function AddItemsPage() {
return products.find(p => p.id === id)?.name || `#${id}` return products.find(p => p.id === id)?.name || `#${id}`
} }
// If we have a print ack with failures, show the ack overlay instead of the normal UI
if (printAck && !printAck.allOk) {
return (
<div className="page">
<header className="top-bar">
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}></button>
<span className="top-bar__title">Αποτέλεσμα εκτύπωσης</span>
</header>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: 20 }}>
<div style={{
background: '#7f1d1d', borderRadius: 14, padding: '16px 18px',
border: '1px solid #ef4444',
}}>
<p style={{ fontWeight: 700, fontSize: 16, color: '#fca5a5', marginBottom: 8 }}>
Πρόβλημα εκτύπωσης
</p>
<p style={{ fontSize: 14, color: '#fca5a5' }}>
Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
Τα αντικείμενα παραμένουν ως "σχέδιο" δεν έχουν σταλεί στην κουζίνα/μπαρ.
</p>
</div>
{printAck.results.map((r, i) => (
<div
key={i}
style={{
display: 'flex', alignItems: 'center', gap: 12,
background: r.success ? '#14532d' : '#7f1d1d',
border: `1px solid ${r.success ? '#22c55e' : '#ef4444'}`,
borderRadius: 12, padding: '12px 16px',
}}
>
<span style={{ fontSize: 22 }}>{r.success ? '✓' : '✗'}</span>
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 600, fontSize: 15, color: r.success ? '#86efac' : '#fca5a5' }}>
{r.printer_name}
</p>
{r.error && (
<p style={{ fontSize: 12, color: '#fca5a5', marginTop: 2 }}>{r.error}</p>
)}
</div>
</div>
))}
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button
className="btn btn--secondary"
style={{ flex: 1 }}
onClick={() => navigate(`/tables/${tableId}`)}
>
Επιστροφή στο τραπέζι
</button>
<button
className="btn btn--primary"
style={{ flex: 1 }}
onClick={async () => {
setPrintAck(null)
setCart([])
navigate(`/tables/${tableId}`)
}}
>
Εντάξει, συνέχεια
</button>
</div>
</div>
</div>
)
}
return ( return (
<div className="page"> <div className="page">
<header className="top-bar"> <header className="top-bar">
@@ -91,6 +171,17 @@ export default function AddItemsPage() {
{error && <p className="error-msg">{error}</p>} {error && <p className="error-msg">{error}</p>}
{/* Success flash when all printers OK */}
{printAck?.allOk && (
<div style={{
background: '#14532d', border: '1px solid #22c55e',
borderRadius: 10, padding: '10px 14px',
color: '#86efac', fontWeight: 600, fontSize: 14, textAlign: 'center',
}}>
Εκτυπώθηκε επιτυχώς μεταφορά
</div>
)}
<button <button
className="btn btn--primary btn--lg" className="btn btn--primary btn--lg"
style={{ width: '100%', marginTop: 16 }} style={{ width: '100%', marginTop: 16 }}

View File

@@ -15,11 +15,8 @@ export default function LoginPage() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
const params = new URLSearchParams({ username, password: pin }) const { data } = await client.post('/api/auth/login', { username, pin })
const { data } = await client.post('/api/auth/login', params, { login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
login({ id: data.user_id, username: data.username, role: data.role }, data.access_token)
navigate('/tables') navigate('/tables')
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία') setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')

View File

@@ -44,10 +44,8 @@ export default function TableDetailPage() {
const activeItems = order?.items?.filter(i => i.status === 'active') || [] const activeItems = order?.items?.filter(i => i.status === 'active') || []
const allPaid = order && activeItems.length === 0 const allPaid = order && activeItems.length === 0
// Any waiter whose zone covers this table can interact with orders on it
const isMyOrder = order && ( const canInteract = !!order
order.opened_by === user?.id || order.waiters?.some(w => w.waiter_id === user?.id)
)
async function openOrder() { async function openOrder() {
try { try {
@@ -123,12 +121,12 @@ export default function TableDetailPage() {
<div className="detail-body"> <div className="detail-body">
<OrderSummary <OrderSummary
order={order} order={order}
selectable={isMyOrder && !paying} selectable={canInteract && !paying}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleItem} onToggle={toggleItem}
/> />
{isMyOrder && activeItems.length > 0 && ( {canInteract && activeItems.length > 0 && (
<div style={{ padding: '4px 12px 8px' }}> <div style={{ padding: '4px 12px 8px' }}>
<button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}> <button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}>
{allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'} {allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
@@ -136,7 +134,7 @@ export default function TableDetailPage() {
</div> </div>
)} )}
{isMyOrder && ( {canInteract && (
<div className="action-bar"> <div className="action-bar">
<button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}> <button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}>
+ Προσθήκη + Προσθήκη
@@ -159,12 +157,6 @@ export default function TableDetailPage() {
</button> </button>
</div> </div>
)} )}
{!isMyOrder && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>
Ανάγνωση μόνο άλλος σερβιτόρος
</p>
)}
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import TableCard from '../components/TableCard' import TableCard from '../components/TableCard'
import ConnectionBanner from '../components/ConnectionBanner' import ConnectionBanner from '../components/ConnectionBanner'
@@ -11,9 +11,13 @@ const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύ
export default function TableListPage() { export default function TableListPage() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const [tables, setTables] = useState([]) const [tables, setTables] = useState([])
const [groups, setGroups] = useState([])
const [orders, setOrders] = useState([]) const [orders, setOrders] = useState([])
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [zoneOpen, setZoneOpen] = useState(false)
const [selectedZones, setSelectedZones] = useState(new Set())
const zoneRef = useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -22,14 +26,25 @@ export default function TableListPage() {
return () => window.removeEventListener('backend-offline', handler) return () => window.removeEventListener('backend-offline', handler)
}, []) }, [])
// Close zone dropdown on outside click
useEffect(() => {
function onClick(e) {
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
async function load() { async function load() {
try { try {
const [tablesRes, ordersRes] = await Promise.all([ const [tablesRes, ordersRes, groupsRes] = await Promise.all([
client.get('/api/tables/'), client.get('/api/tables/'),
client.get('/api/orders/my'), client.get('/api/orders/my'),
client.get('/api/tables/groups'),
]) ])
setTables(tablesRes.data) setTables(tablesRes.data)
setOrders(ordersRes.data) setOrders(ordersRes.data)
setGroups(groupsRes.data)
setOffline(false) setOffline(false)
} catch {} } catch {}
} }
@@ -40,10 +55,19 @@ export default function TableListPage() {
return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status)) return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
} }
function toggleZone(id) {
setSelectedZones(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const filtered = tables.filter(t => { const filtered = tables.filter(t => {
const order = getOrder(t.id) const order = getOrder(t.id)
if (filter === 'free') return !order if (filter === 'free' && order) return false
if (filter === 'mine') return order && order.waiters?.some(w => w.waiter_id === user?.id) if (filter === 'mine' && !(order && order.waiters?.some(w => w.waiter_id === user?.id))) return false
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
return true return true
}) })
@@ -52,6 +76,8 @@ export default function TableListPage() {
navigate('/login') navigate('/login')
} }
const zoneActive = selectedZones.size > 0
return ( return (
<div className="page"> <div className="page">
<header className="top-bar"> <header className="top-bar">
@@ -68,6 +94,66 @@ export default function TableListPage() {
{FILTER_LABELS[f]} {FILTER_LABELS[f]}
</button> </button>
))} ))}
{/* Zone filter */}
<div ref={zoneRef} style={{ position: 'relative' }}>
<button
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
onClick={() => setZoneOpen(o => !o)}
>
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
</button>
{zoneOpen && (
<div style={{
position: 'absolute', top: '110%', right: 0, zIndex: 100,
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', minWidth: 180, padding: 8,
}}>
<button
onClick={() => setSelectedZones(new Set())}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.size === 0 ? '#fff' : '#374151',
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Όλες οι ζώνες
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => toggleZone(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has(g.id) ? '#fff' : '#374151',
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
{g.name}
</button>
))}
{tables.some(t => !t.group_id) && (
<button
onClick={() => toggleZone('none')}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has('none') ? '#fff' : '#374151',
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Χωρίς ζώνη
</button>
)}
</div>
)}
</div>
</div> </div>
<div className="table-grid"> <div className="table-grid">

View File

@@ -23,6 +23,9 @@ export default defineConfig({
globPatterns: ['**/*.{js,css,html,ico,png,svg}'], globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [], runtimeCaching: [],
}, },
devOptions: {
enabled: true,
},
}), }),
], ],
}) })