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>
This commit is contained in:
2026-04-24 17:37:34 +03:00
parent ee51e52acf
commit 603fd45eaa
18 changed files with 2243 additions and 256 deletions

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,11 +9,11 @@ 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
@@ -36,6 +36,45 @@ def _run_migrations():
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0", "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_preference_sets ADD COLUMN shared_subset TEXT",
"ALTER TABLE product_options ADD COLUMN sub_choices 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: for sql in migrations:
try: try:
@@ -70,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

@@ -22,6 +22,8 @@ class Order(Base):
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", cascade="all, delete-orphan") 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

@@ -13,6 +13,7 @@ class TableGroup(Base):
color = Column(String, nullable=True) 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

@@ -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),
): ):
"""Payments collected per waiter — based on paid_by on order items."""
if from_dt and to_dt:
start = datetime.fromisoformat(from_dt)
end = datetime.fromisoformat(to_dt)
else:
target = report_date or date.today() target = report_date or date.today()
start = datetime.combine(target, datetime.min.time()) start = datetime.combine(target, datetime.min.time())
end = start + timedelta(days=1) 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,
@@ -69,6 +69,19 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
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)
# 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() tables = q.order_by(Table.group_id, Table.number).all()
active_table_ids = { active_table_ids = {

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

@@ -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

@@ -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,6 +206,20 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div> </div>
</div> </div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
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'}`}
>
{label}
</button>
))}
</div>
{tab === 'overview' && <>
{/* Waiters */} {/* Waiters */}
<div className="card p-4"> <div className="card p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2> <h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
@@ -165,6 +266,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p> <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>} {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> <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>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<StatusBadge status={item.status} /> <StatusBadge status={item.status} />
@@ -191,9 +298,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div> </div>
{/* Actions */} {/* Actions */}
{isOpen && !readOnly && (
<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,6 +307,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
Πληρωμή όλων Πληρωμή όλων
</button> </button>
)} )}
{isOpen && !readOnly && (
<>
<button <button
onClick={() => setConfirmAction({ type: 'closeOrder' })} onClick={() => setConfirmAction({ type: 'closeOrder' })}
className="btn btn-secondary" className="btn btn-secondary"
@@ -213,6 +321,20 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
> >
Ακύρωση παραγγελίας Ακύρωση παραγγελίας
</button> </button>
</>
)}
<button
onClick={() => setShowPrintModal(true)}
className="btn btn-secondary"
>
🖨 Εκτύπωση
</button>
</div>
</>}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />
</div> </div>
)} )}
@@ -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>
) )
} }

View File

@@ -1,12 +1,20 @@
import { useState } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import toast from 'react-hot-toast'
import client from '../api/client' import client from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { DateTimeInput } from '../components/DateInput'
function today() { function today() {
return new Date().toISOString().slice(0, 10) return new Date().toISOString().slice(0, 10)
} }
function todayStart() { return today() + 'T00:00' }
function todayEnd() { return today() + 'T23:59' }
function fmtDt(dt) {
if (!dt) return '—'
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
}
function csvDownload(rows, filename) { function csvDownload(rows, filename) {
const header = Object.keys(rows[0]).join(',') const header = Object.keys(rows[0]).join(',')
@@ -18,128 +26,760 @@ function csvDownload(rows, filename) {
a.click() a.click()
} }
export default function ReportsPage() { // ── Shared label class for consistent toolbar height ─────────────────────────
const [tab, setTab] = useState('shift') // All toolbar controls use h-10 so they align with date inputs
const [shiftDate, setShiftDate] = useState(today())
const [historyFilters, setHistoryFilters] = useState({ from: today(), to: today(), status: '' }) const CTRL = 'h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500'
const SELECT = CTRL + ' pr-8 appearance-none bg-[url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="%236b7280"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>\')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat'
const BTN_SEC = 'h-10 px-4 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap'
const BTN_PRI = 'h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors whitespace-nowrap'
// ── Shared modal close hook (ESC + click-outside) ────────────────────────────
function useModalClose(onClose) {
const overlayRef = useRef(null)
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const onOverlayClick = useCallback((e) => {
if (e.target === overlayRef.current) onClose()
}, [onClose])
return { overlayRef, onOverlayClick }
}
// ── Print Modal (waiter / printer reports) ────────────────────────────────────
function PrintModal({ title, onClose, onPrint, printers, defaultFrom, defaultTo }) {
const [mode, setMode] = useState('simple')
const [fromDt, setFromDt] = useState(defaultFrom || todayStart())
const [toDt, setToDt] = useState(defaultTo || todayEnd())
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
const { overlayRef, onOverlayClick } = useModalClose(onClose)
function submit() {
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
onPrint({ mode, fromDt, toDt, printerId: Number(printerId) })
onClose()
}
return ( return (
<div className="space-y-6 max-w-4xl"> <div ref={overlayRef} onClick={onOverlayClick} 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-md p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Τύπος εκτύπωσης</p>
<div className="flex gap-2">
<button onClick={() => setMode('simple')} className={`flex-1 ${mode === 'simple' ? BTN_PRI : BTN_SEC}`}>Απλή</button>
<button onClick={() => setMode('extensive')} className={`flex-1 ${mode === 'extensive' ? BTN_PRI : BTN_SEC}`}>Αναλυτική</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
<DateTimeInput className={CTRL + ' w-full'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
<DateTimeInput className={CTRL + ' w-full'} value={toDt} onChange={e => setToDt(e.target.value)} />
</div>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
<select className={SELECT + ' 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 pt-1">
<button onClick={onClose} className={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
</div>
</div>
</div>
)
}
// ── Printer select modal (single order) ───────────────────────────────────────
function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
const { overlayRef, onOverlayClick } = useModalClose(onClose)
function submit() {
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
onPrint(Number(printerId)); onClose()
}
return (
<div ref={overlayRef} onClick={onOverlayClick} 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="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
<select className={SELECT + ' 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={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
</div>
</div>
</div>
)
}
// ── No-printers fallback modal ────────────────────────────────────────────────
function NoPrintersModal({ onClose }) {
const { overlayRef, onOverlayClick } = useModalClose(onClose)
return (
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<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={onClose} className={`w-full ${BTN_SEC}`}>Κλείσιμο</button>
</div>
</div>
)
}
// ── Printer Details Modal ─────────────────────────────────────────────────────
function PrinterDetailsModal({ printerRow, tableMap, onClose }) {
const orderData = printerRow?.order_data ?? []
const { overlayRef, onOverlayClick } = useModalClose(onClose)
return (
<div ref={overlayRef} onClick={onOverlayClick} 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-2xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-800">Εκτυπωτής: {printerRow?.printer_name}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
<span><span className="text-gray-500">Εργασίες:</span> <strong>{printerRow?.print_jobs}</strong></span>
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{printerRow?.orders}</strong></span>
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{printerRow?.items}</strong></span>
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">{printerRow?.total?.toFixed(2)}</strong></span>
</div>
<div className="overflow-y-auto flex-1">
{orderData.length === 0 && (
<p className="text-gray-400 p-6 text-center">Δεν υπάρχουν αναλυτικά δεδομένα.<br/><span className="text-xs">Χρησιμοποιήστε εκτύπωση Αναλυτική για πλήρη λεπτομέρεια.</span></p>
)}
{orderData.length > 0 && (
<div className="divide-y divide-gray-100">
{orderData.map((od, i) => (
<div key={i} className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800">
{od.time} {tableMap[od.table_id] ?? od.table}
</span>
<span className="font-semibold text-primary-700">{od.total.toFixed(2)}</span>
</div>
{od.items?.length > 0 && (
<ul className="mt-1 space-y-0.5">
{od.items.map((item, j) => (
<li key={j} className="text-sm text-gray-600 pl-4">
{item.quantity} × {item.name}
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-100">
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
</div>
</div>
</div>
)
}
// ── Payers Modal (multi-waiter payment breakdown) ────────────────────────────
function PayersModal({ order, waiterMap, onClose }) {
const { overlayRef, onOverlayClick } = useModalClose(onClose)
const paidItems = order.items.filter(i => i.status === 'paid' && i.paid_by)
// Group items by (waiter_id, paid_at rounded to minute) to create payment groups
const groups = {}
for (const item of paidItems) {
const key = `${item.paid_by}__${item.paid_at}`
if (!groups[key]) {
groups[key] = { waiter_id: item.paid_by, paid_at: item.paid_at, payment_method: item.payment_method, items: [] }
}
groups[key].items.push(item)
}
const paymentGroups = Object.values(groups).sort((a, b) => (a.paid_at || '') < (b.paid_at || '') ? -1 : 1)
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
return (
<div ref={overlayRef} onClick={onOverlayClick} 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-lg max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-800">Πληρωμές Παραγγελία #{order.id}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{paymentGroups.map((g, i) => {
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
return (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<div>
<span className="font-semibold text-gray-800">{fmtDt(g.paid_at)}</span>
<span className="mx-2 text-gray-400">·</span>
<span className="text-primary-700 font-medium">{waiterName}</span>
{g.payment_method && (
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{pmLabel(g.payment_method)}</span>
)}
</div>
<span className="font-bold text-gray-800">{groupTotal.toFixed(2)}</span>
</div>
<ul className="pl-4 space-y-0.5">
{g.items.map(it => (
<li key={it.id} className="flex justify-between text-sm text-gray-600">
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
<span>{(it.unit_price * it.quantity).toFixed(2)}</span>
</li>
))}
</ul>
</div>
)
})}
</div>
<div className="px-6 py-4 border-t border-gray-100">
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
</div>
</div>
</div>
)
}
// ── Order Details Modal ───────────────────────────────────────────────────────
function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPrint }) {
const [showPrint, setShowPrint] = useState(false)
const { overlayRef, onOverlayClick } = useModalClose(onClose)
if (!order) return null
const activeItems = order.items.filter(i => i.status !== 'cancelled')
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
// Per-waiter subtotals for paid items
const waiterTotals = {}
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
const wid = item.paid_by
if (!waiterTotals[wid]) waiterTotals[wid] = 0
waiterTotals[wid] += item.unit_price * item.quantity
}
const waiterTotalEntries = Object.entries(waiterTotals)
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
return (
<div ref={overlayRef} onClick={onOverlayClick} 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-5xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-800">Παραγγελία #{order.id}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
<span><span className="text-gray-500">Τραπέζι:</span> <strong>{tableMap[order.table_id] || `#${order.table_id}`}</strong></span>
<span><span className="text-gray-500">Ανοίχτηκε:</span> <strong>{fmtDt(order.opened_at)}</strong></span>
<span><span className="text-gray-500">Έκλεισε:</span> <strong>{fmtDt(order.closed_at)}</strong></span>
<span><StatusBadge status={order.status} /></span>
{order.notes && <span className="w-full text-gray-500 italic">"{order.notes}"</span>}
</div>
<div className="overflow-y-auto flex-1">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
<tr>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Προϊόν</th>
<th className="text-center px-4 py-3 font-semibold text-gray-600">Ποσ.</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Τιμή/τμχ</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατ.</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τύπος</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{order.items.map(item => (
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{item.unit_price.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">{(item.unit_price * item.quantity).toFixed(2)}</td>
<td className="px-4 py-2.5"><StatusBadge status={item.status} /></td>
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
<td className="px-4 py-2.5 text-gray-600">{item.paid_by ? (waiterMap[item.paid_by] || `#${item.paid_by}`) : '—'}</td>
</tr>
))}
</tbody>
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
{waiterTotalEntries.length > 1 && waiterTotalEntries.map(([wid, wTotal]) => (
<tr key={wid} className="text-sm">
<td colSpan={3} className="px-4 py-2 text-gray-600">Σύνολο {waiterMap[wid] || `#${wid}`}</td>
<td className="px-4 py-2 text-right text-gray-700">{wTotal.toFixed(2)}</td>
<td colSpan={4} />
</tr>
))}
<tr>
<td colSpan={3} className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
<td className="px-4 py-3 text-right font-bold text-primary-700">{total.toFixed(2)}</td>
<td colSpan={4} />
</tr>
</tfoot>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-100 flex gap-3">
{printers.length > 0 && (
<button onClick={() => setShowPrint(true)} className={BTN_SEC}>🖨 Εκτύπωση</button>
)}
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
</div>
</div>
{showPrint && (
<PrintOrderModal
printers={printers}
onClose={() => setShowPrint(false)}
onPrint={(printerId) => { onPrint(order.id, printerId); setShowPrint(false) }}
/>
)}
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function ReportsPage() {
const [tab, setTab] = useState('shift')
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
const TABS = [
['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
['printers', 'Σύνοψη εκτυπωτών'],
['history', 'Ιστορικό παραγγελιών'],
]
return (
<div className="space-y-6">
<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"> <div className="flex gap-2 flex-wrap">
<button onClick={() => setTab('shift')} className={`btn ${tab === 'shift' ? 'btn-primary' : 'btn-secondary'}`}>Σύνοψη βάρδιας</button> {TABS.map(([key, label]) => (
<button onClick={() => setTab('history')} className={`btn ${tab === 'history' ? 'btn-primary' : 'btn-secondary'}`}>Ιστορικό παραγγελιών</button> <button key={key} onClick={() => setTab(key)} className={`btn ${tab === key ? 'btn-primary' : 'btn-secondary'}`}>{label}</button>
))}
</div> </div>
{tab === 'shift' && <ShiftTab date={shiftDate} setDate={setShiftDate} />} {tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
{tab === 'printers' && <PrintersTab />}
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />} {tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
</div> </div>
) )
} }
function ShiftTab({ date, setDate }) { // ── Shift / Waiter Totals Tab ─────────────────────────────────────────────────
const { data, isLoading } = useQuery({
queryKey: ['report-shift', date], function WaiterShiftDetailsModal({ row, tableMap, onClose }) {
queryFn: () => client.get(`/api/reports/shift?date=${date}`).then(r => r.data), const { overlayRef, onOverlayClick } = useModalClose(onClose)
return (
<div ref={overlayRef} onClick={onOverlayClick} 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-2xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-800">Σερβιτόρος: {row.waiter_name}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{row.orders}</strong></span>
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{row.items}</strong></span>
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">{row.total.toFixed(2)}</strong></span>
</div>
<div className="overflow-y-auto flex-1">
{(row.order_data ?? []).length === 0 && (
<p className="text-gray-400 p-6 text-center">Δεν βρέθηκαν παραγγελίες.</p>
)}
{(row.order_data ?? []).length > 0 && (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
<tr>
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Άνοιγμα</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κλείσιμο</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{row.order_data.map(od => (
<tr key={od.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-500">{od.id}</td>
<td className="px-4 py-2 font-medium text-gray-800">{od.table}</td>
<td className="px-4 py-2 text-gray-600">{od.time_open}</td>
<td className="px-4 py-2 text-gray-600">{od.time_close || '—'}</td>
<td className="px-4 py-2 text-right font-semibold text-gray-800">{od.total.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-6 py-4 border-t border-gray-100">
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
</div>
</div>
</div>
)
}
function ShiftTab({ endpoint, title }) {
const [fromDt, setFromDt] = useState(todayStart())
const [toDt, setToDt] = useState(todayEnd())
const [printTarget, setPrintTarget] = useState(null) // waiter row
const [detailTarget, setDetailTarget] = useState(null) // waiter row
const { data, isLoading, refetch } = useQuery({
queryKey: ['report-shift', endpoint, fromDt, toDt],
queryFn: () => client.get(`${endpoint}?from=${encodeURIComponent(fromDt)}&to=${encodeURIComponent(toDt)}`).then(r => r.data),
}) })
const rows = data const { data: printers = [] } = useQuery({
? Object.entries(data.waiters).map(([name, s]) => ({ queryKey: ['printers'],
Σερβιτόρος: name, queryFn: () => client.get('/api/system/printers').then(r => r.data),
Παραγγελίες: s.orders, staleTime: 60_000,
'Αντικείμενα': s.items, })
'Σύνολο (€)': s.total.toFixed(2),
}))
: []
const grandTotal = rows.reduce((s, r) => s + parseFloat(r['Σύνολο (€)']), 0) const { data: tables = [] } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
staleTime: 60_000,
})
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
const printMutation = useMutation({
mutationFn: (body) => client.post('/api/reports/print/waiter', body),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
// New API returns { waiters: [{waiter_id, waiter_name, orders, items, total, order_data}] }
const rows = data?.waiters ?? []
const grandTotal = rows.reduce((s, r) => s + r.total, 0)
const grandOrders = rows.reduce((s, r) => s + r.orders, 0)
const grandItems = rows.reduce((s, r) => s + r.items, 0)
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
printMutation.mutate({ waiter_name: printTarget.waiter_name, printer_id: printerId, mode, from_dt: fd, to_dt: td })
}
const csvRows = rows.map(r => ({
Σερβιτόρος: r.waiter_name,
Παραγγελίες: r.orders,
Αντικείμενα: r.items,
'Σύνολο (€)': r.total.toFixed(2),
}))
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4"> {/* Toolbar */}
<div className="flex items-end gap-3 flex-wrap">
<div> <div>
<label className="label">Ημερομηνία</label> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
<input type="date" className="input w-44" value={date} onChange={e => setDate(e.target.value)} /> <DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
</div> </div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
</div>
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
{rows.length > 0 && ( {rows.length > 0 && (
<button <button onClick={() => csvDownload(csvRows, `shift_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
onClick={() => csvDownload(rows, `shift_${date}.csv`)}
className="btn btn-secondary self-end"
>
Εξαγωγή CSV Εξαγωγή CSV
</button> </button>
)} )}
</div> </div>
{isLoading && <p className="text-gray-400">Φόρτωση</p>} {isLoading && <p className="text-gray-400">Φόρτωση</p>}
{!isLoading && rows.length === 0 && ( {!isLoading && rows.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.</p> <p className="text-center text-gray-400 py-12">Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.</p>
)} )}
{rows.length > 0 && ( {rows.length > 0 && (
<div className="card overflow-hidden"> <div className="card overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100"> <thead className="bg-gray-50 border-b border-gray-100">
<tr> <tr>
{Object.keys(rows[0]).map(h => ( <th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
<th key={h} className="text-left px-4 py-3 font-semibold text-gray-600">{h}</th> <th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
))} <th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο ()</th>
<th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{rows.map((r, i) => ( {rows.map((r, i) => (
<tr key={i} className="hover:bg-gray-50"> <tr key={i} className="hover:bg-gray-50">
{Object.values(r).map((v, j) => ( <td className="px-4 py-3 font-medium text-gray-800">{r.waiter_name}</td>
<td key={j} className="px-4 py-3 text-gray-800">{v}</td> <td className="px-4 py-3 text-gray-700">{r.orders}</td>
))} <td className="px-4 py-3 text-gray-700">{r.items}</td>
<td className="px-4 py-3 text-gray-800">{r.total.toFixed(2)}</td>
<td className="px-4 py-3">
<div className="flex gap-2 justify-end">
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
<button onClick={() => setPrintTarget(r)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot className="border-t-2 border-gray-200 bg-gray-50"> <tfoot className="border-t-2 border-gray-200 bg-gray-50">
<tr> <tr>
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td> <td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Παραγγελίες'], 0)}</td> <td className="px-4 py-3 font-bold">{grandOrders}</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Αντικείμενα'], 0)}</td> <td className="px-4 py-3 font-bold">{grandItems}</td>
<td className="px-4 py-3 font-bold text-primary-700">{grandTotal.toFixed(2)}</td> <td className="px-4 py-3 font-bold text-primary-700">{grandTotal.toFixed(2)}</td>
<td />
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
)} )}
{printTarget && printers.length > 0 && (
<PrintModal
title={`Εκτύπωση: ${printTarget.waiter_name}`}
printers={printers}
defaultFrom={fromDt}
defaultTo={toDt}
onClose={() => setPrintTarget(null)}
onPrint={handlePrint}
/>
)}
{printTarget && printers.length === 0 && <NoPrintersModal onClose={() => setPrintTarget(null)} />}
{detailTarget && (
<WaiterShiftDetailsModal
row={detailTarget}
tableMap={tableMap}
onClose={() => setDetailTarget(null)}
/>
)}
</div> </div>
) )
} }
function HistoryTab({ filters, setFilters }) { // ── Printer Totals Tab ────────────────────────────────────────────────────────
const navigate = useNavigate()
const [page, setPage] = useState(1)
const params = new URLSearchParams({ from: filters.from, to: filters.to + 'T23:59:59', page }) function PrintersTab() {
const [fromDt, setFromDt] = useState(todayStart())
const [toDt, setToDt] = useState(todayEnd())
const [printTarget, setPrintTarget] = useState(null) // printer_id
const [detailTarget, setDetailTarget] = useState(null) // full printer row object
const params = new URLSearchParams({ from: fromDt, to: toDt })
const { data, isLoading, refetch } = useQuery({
queryKey: ['report-printers', fromDt, toDt],
queryFn: () => client.get(`/api/reports/printers?${params}`).then(r => r.data),
})
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 60_000,
})
const { data: tables = [] } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
staleTime: 60_000,
})
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
const printMutation = useMutation({
mutationFn: (body) => client.post('/api/reports/print/printer', body),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
const rows = data?.printers ?? []
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
printMutation.mutate({ printer_target_id: printTarget, printer_id: printerId, mode, from_dt: fd, to_dt: td })
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-end gap-3 flex-wrap">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
</div>
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
</div>
{isLoading && <p className="text-gray-400">Φόρτωση</p>}
{!isLoading && rows.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν δεδομένα για αυτό το διάστημα.</p>
)}
{rows.length > 0 && (
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εκτυπωτής</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εργασίες</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο ()</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{rows.map((r, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-800">{r.printer_name}</td>
<td className="px-4 py-3 text-gray-700">{r.print_jobs}</td>
<td className="px-4 py-3 text-gray-700">{r.orders}</td>
<td className="px-4 py-3 text-gray-700">{r.items}</td>
<td className="px-4 py-3 font-semibold text-gray-800">{r.total.toFixed(2)}</td>
<td className="px-4 py-3">
<div className="flex gap-2 justify-end">
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
<button onClick={() => setPrintTarget(r.printer_id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
</div>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
<tr>
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.print_jobs, 0)}</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.orders, 0)}</td>
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.items, 0)}</td>
<td className="px-4 py-3 font-bold text-primary-700">{rows.reduce((s, r) => s + r.total, 0).toFixed(2)}</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
{printTarget !== null && printers.length > 0 && (
<PrintModal
title={`Εκτύπωση: ${rows.find(r => r.printer_id === printTarget)?.printer_name ?? 'Εκτυπωτής'}`}
printers={printers}
defaultFrom={fromDt}
defaultTo={toDt}
onClose={() => setPrintTarget(null)}
onPrint={handlePrint}
/>
)}
{detailTarget && (
<PrinterDetailsModal
printerRow={detailTarget}
tableMap={tableMap}
onClose={() => setDetailTarget(null)}
/>
)}
</div>
)
}
// ── Order History Tab ─────────────────────────────────────────────────────────
function HistoryTab({ filters, setFilters }) {
const [page, setPage] = useState(1)
const [detailOrder, setDetailOrder] = useState(null) // full order object for modal
const [printOrderId, setPrintOrderId] = useState(null)
const [payersModal, setPayersModal] = useState(null) // order object for multi-payer modal
const { data: tables = [] } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
staleTime: 60_000,
})
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 60_000,
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.full_name || w.username]))
const params = new URLSearchParams({ from: filters.from, to: filters.to, page })
if (filters.status) params.set('status', filters.status) if (filters.status) params.set('status', filters.status)
if (filters.table_id) params.set('table_id', filters.table_id)
const { data: orders = [], isLoading } = useQuery({ const { data: orders = [], isLoading } = useQuery({
queryKey: ['order-history', filters, page], queryKey: ['order-history', filters, page],
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data), queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
}) })
const printMutation = useMutation({
mutationFn: ({ orderId, printerId }) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) } function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
const visibleOrders = filters.hideEmpty
? orders.filter(o => o.items.some(i => i.status !== 'cancelled'))
: orders
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-end gap-4"> {/* Toolbar */}
<div className="flex flex-wrap items-end gap-3">
<div> <div>
<label className="label">Από</label> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
<input type="date" className="input w-40" value={filters.from} onChange={e => setF('from', e.target.value)} /> <DateTimeInput className={CTRL + ' w-52'} value={filters.from} onChange={e => setF('from', e.target.value)} />
</div> </div>
<div> <div>
<label className="label">Έως</label> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
<input type="date" className="input w-40" value={filters.to} onChange={e => setF('to', e.target.value)} /> <DateTimeInput className={CTRL + ' w-52'} value={filters.to} onChange={e => setF('to', e.target.value)} />
</div> </div>
<div> <div>
<label className="label">Κατάσταση</label> <label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Κατάσταση</label>
<select className="input w-44" value={filters.status} onChange={e => setF('status', e.target.value)}> <select className={SELECT + ' w-44'} value={filters.status} onChange={e => setF('status', e.target.value)}>
<option value="">Όλες</option> <option value="">Όλες</option>
<option value="open">Ανοιχτές</option> <option value="open">Ανοιχτές</option>
<option value="partially_paid">Μερική πληρωμή</option> <option value="partially_paid">Μερική πληρωμή</option>
@@ -147,48 +787,109 @@ function HistoryTab({ filters, setFilters }) {
<option value="cancelled">Ακυρωμένες</option> <option value="cancelled">Ακυρωμένες</option>
</select> </select>
</div> </div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Τραπέζι</label>
<select className={SELECT + ' w-44'} value={filters.table_id} onChange={e => setF('table_id', e.target.value)}>
<option value="">Όλα</option>
{tables.map(t => (
<option key={t.id} value={t.id}>{t.label || `T${t.number}`}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 h-10 cursor-pointer select-none text-sm text-gray-700">
<input
type="checkbox"
className="w-4 h-4 rounded accent-primary-700"
checked={filters.hideEmpty}
onChange={e => setF('hideEmpty', e.target.checked)}
/>
Απόκρυψη κενών
</label>
</div> </div>
{isLoading && <p className="text-gray-400">Φόρτωση</p>} {isLoading && <p className="text-gray-400">Φόρτωση</p>}
{!isLoading && visibleOrders.length === 0 && (
{!isLoading && orders.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p> <p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
)} )}
{orders.length > 0 && ( {visibleOrders.length > 0 && (
<div className="card overflow-hidden"> <div className="card overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100"> <thead className="bg-gray-50 border-b border-gray-100">
<tr> <tr>
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th> <th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th> <th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Ανοίχτηκε</th> <th className="text-left px-4 py-3 font-semibold text-gray-600">Ανοίχτηκε</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Έκλεισε</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατάσταση</th> <th className="text-left px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σημείωση</th>
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th> <th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
<th /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{orders.map(o => { {visibleOrders.map(o => {
const total = o.items const total = o.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)
// Opener / closer
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`
const closerName = o.closed_by ? (waiterMap[o.closed_by] || `#${o.closed_by}`) : null
// Latest payment info from items
const paidItems = o.items.filter(i => i.status === 'paid' && i.paid_by)
const payerIds = [...new Set(paidItems.map(i => i.paid_by))]
const latestPaidAt = paidItems.reduce((max, i) => (!max || i.paid_at > max) ? i.paid_at : max, null)
return ( return (
<tr key={o.id} className="hover:bg-gray-50"> <tr key={o.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{o.id}</td> <td className="px-4 py-3 text-gray-500">{o.id}</td>
<td className="px-4 py-3 font-medium text-gray-800">{o.table_id}</td> <td className="px-4 py-3 font-medium text-gray-800">{tableMap[o.table_id] || `#${o.table_id}`}</td>
<td className="px-4 py-3 text-gray-600"> <td className="px-4 py-3 text-gray-600">
{new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })} <div className="whitespace-nowrap">{fmtDt(o.opened_at)}</div>
<div className="text-xs text-gray-400">{openerName}</div>
</td>
<td className="px-4 py-3 text-gray-600">
{o.closed_at ? <>
<div className="whitespace-nowrap">{fmtDt(o.closed_at)}</div>
{closerName && <div className="text-xs text-gray-400">{closerName}</div>}
</> : '—'}
</td>
<td className="px-4 py-3 text-gray-600">
{payerIds.length === 0 ? '—' : payerIds.length === 1 ? (
<>
<div className="whitespace-nowrap text-xs text-green-700 font-medium">{waiterMap[payerIds[0]] || `#${payerIds[0]}`}</div>
{latestPaidAt && <div className="text-xs text-gray-400">{fmtDt(latestPaidAt)}</div>}
</>
) : (
<button
onClick={() => setPayersModal(o)}
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 transition-colors"
>
{payerIds.length} σερβιτόροι
</button>
)}
</td> </td>
<td className="px-4 py-3"><StatusBadge status={o.status} /></td> <td className="px-4 py-3"><StatusBadge status={o.status} /></td>
<td className="px-4 py-3 text-gray-500 text-xs max-w-[120px] truncate" title={o.notes || ''}>{o.notes || '—'}</td>
<td className="px-4 py-3 text-right font-semibold text-gray-800">{total.toFixed(2)}</td> <td className="px-4 py-3 text-right font-semibold text-gray-800">{total.toFixed(2)}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3">
<div className="flex gap-1 justify-end">
<button <button
onClick={() => navigate(`/orders/${o.id}`)} onClick={() => setDetailOrder(o)}
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7" className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
> >
Προβολή Λεπτομέρειες
</button> </button>
<button
onClick={() => setPrintOrderId(o.id)}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7"
>
🖨
</button>
</div>
</td> </td>
</tr> </tr>
) )
@@ -198,12 +899,38 @@ function HistoryTab({ filters, setFilters }) {
</div> </div>
)} )}
{/* Pagination */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"> Προηγ.</button> <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"> Προηγ.</button>
<span className="text-sm text-gray-500">Σελίδα {page}</span> <span className="text-sm text-gray-500">Σελίδα {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. </button> <button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. </button>
</div> </div>
{detailOrder && (
<OrderDetailsModal
order={detailOrder}
tableMap={tableMap}
waiterMap={waiterMap}
printers={printers}
onClose={() => setDetailOrder(null)}
onPrint={(orderId, printerId) => printMutation.mutate({ orderId, printerId })}
/>
)}
{payersModal && (
<PayersModal
order={payersModal}
waiterMap={waiterMap}
onClose={() => setPayersModal(null)}
/>
)}
{printOrderId !== null && printers.length > 0 && (
<PrintOrderModal
printers={printers}
onClose={() => setPrintOrderId(null)}
onPrint={(printerId) => { printMutation.mutate({ orderId: printOrderId, printerId }); setPrintOrderId(null) }}
/>
)}
</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

@@ -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>
)} )}