feat: Feature 2 — waiter order cancellation + report updates

Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA

Manager dashboard:
- Global cancel setting toggle in Settings > Operation > Shift Settings
- Per-waiter can_cancel_orders checkbox in staff modal
- Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage
- ShiftsOverview: Ακυρώσεις column per shift
- Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes,
  grouped/stacked switch, right X-axis for hours, full waiter name on hover
- OrderHistory: cancelled items count column per order
- WorkDaySummary drill-down: cancelled items column in orders tab

Waiter PWA:
- Replace 3 pills with CLEAR | ALL | ACTIONS
- ACTIONS opens ItemActionModal for selected items
- ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER
- ActionsSheet: Cancel Παραγγελίας option (greyed if no permission)
- CancelConfirmModal: requires confirmation before cancelling
- TableListPage: cancel order from long-press quick modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:47:02 +03:00
parent b5b647422a
commit 8a5a6f8be9
16 changed files with 855 additions and 108 deletions

View File

@@ -408,6 +408,9 @@ def _run_migrations():
)""", )""",
# Printer duplicate copies (0 = print once, 1 = print twice, etc.) # Printer duplicate copies (0 = print once, 1 = print twice, etc.)
"ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0", "ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0",
# Waiter cancellation permissions
"ALTER TABLE users ADD COLUMN can_cancel_orders INTEGER NOT NULL DEFAULT 0",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_cancellations_allowed', 'false', CURRENT_TIMESTAMP)",
] ]
for sql in migrations: for sql in migrations:
try: try:

View File

@@ -26,6 +26,8 @@ class User(Base):
created_at = Column(DateTime(timezone=True), default=_utcnow) created_at = Column(DateTime(timezone=True), default=_utcnow)
# Phase 2B — payroll # Phase 2B — payroll
hourly_rate = Column(Float, nullable=True) hourly_rate = Column(Float, nullable=True)
# Waiter cancellation permission
can_cancel_orders = Column(Boolean, default=False, nullable=False)
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")

View File

@@ -42,12 +42,20 @@ class MoveItemsRequest(BaseModel):
target_order_id: int target_order_id: int
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis, print_cancellation_ticket
from models.settings import PosSettings
from services.sse_bus import broadcast_sync from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
def _waiter_can_cancel(user: User, db: Session) -> bool:
"""True only if both the global setting and the per-waiter flag are on."""
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
return global_on and bool(user.can_cancel_orders)
def _can_access_order(order: Order, user: User, db: Session) -> bool: def _can_access_order(order: Order, user: User, db: Session) -> bool:
"""Zone-based access: any waiter whose zone covers the order's table group may act on it.""" """Zone-based access: any waiter whose zone covers the order's table group may act on it."""
if user.role in ("manager", "sysadmin"): if user.role in ("manager", "sysadmin"):
@@ -79,6 +87,25 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
ACTIVE_STATUSES = ["open", "partially_paid", "paid"] ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
@router.get("/cancel-permissions")
def cancel_permissions(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Return whether the current user is allowed to cancel orders."""
is_manager = user.role in ("manager", "sysadmin")
if is_manager:
return {"can_cancel": True, "reason": None}
can = _waiter_can_cancel(user, db)
reason = None
if not can:
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
if not global_on:
reason = "global_disabled"
elif not user.can_cancel_orders:
reason = "user_disabled"
return {"can_cancel": can, "reason": reason}
@router.get("/", response_model=List[OrderOut]) @router.get("/", response_model=List[OrderOut])
def list_orders( def list_orders(
order_status: Optional[str] = None, order_status: Optional[str] = None,
@@ -362,14 +389,41 @@ def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Sess
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def cancel_item(
order_id: int,
item_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
if not item: if not item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
if item.status == "paid":
raise HTTPException(status_code=400, detail="Το αντικείμενο έχει ήδη πληρωθεί")
now = datetime.now(timezone.utc)
item.status = "cancelled" item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id]) _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
db.commit() db.commit()
should_print = print_cancellation if is_manager else True
if should_print:
background_tasks.add_task(print_cancellation_ticket, order_id, [item_id])
@router.post("/{order_id}/pay") @router.post("/{order_id}/pay")
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
@@ -546,14 +600,25 @@ def pay_items_offline(
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def cancel_order(
order_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
order = db.query(Order).filter(Order.id == order_id).first() order = db.query(Order).filter(Order.id == order_id).first()
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Cancel all still-active items
active_items = db.query(OrderItem).filter( active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.order_id == order_id,
OrderItem.status == "active", OrderItem.status == "active",
@@ -561,18 +626,76 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
cancelled_item_ids = [] cancelled_item_ids = []
for item in active_items: for item in active_items:
item.status = "cancelled" item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_item_ids.append(item.id) cancelled_item_ids.append(item.id)
order.status = "cancelled" order.status = "cancelled"
order.closed_at = now order.closed_at = now
order.closed_by = user.id order.closed_by = user.id
note = f"Ακύρωση από manager{len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None note = f"Ακύρωση — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id, _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note) item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit() db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
should_print = print_cancellation if is_manager else True
if should_print and cancelled_item_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_item_ids)
class CancelItemsRequest(BaseModel):
item_ids: List[int]
print_cancellation: bool = True
@router.post("/{order_id}/cancel-items")
def cancel_items_bulk(
order_id: int,
body: CancelItemsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Cancel a selection of items by ID. Used by the waiter PWA multi-select cancel."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
).all()
now = datetime.now(timezone.utc)
cancelled_ids = []
skipped_paid = []
for item in items:
if item.status == "paid":
skipped_paid.append(item.id)
continue
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_ids.append(item.id)
if cancelled_ids:
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids)
db.commit()
should_print = body.print_cancellation if is_manager else True
if should_print and cancelled_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids)
return {"cancelled": cancelled_ids, "skipped_paid": skipped_paid}
@router.put("/{order_id}/assign-waiter") @router.put("/{order_id}/assign-waiter")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):

View File

@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel 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, timezone
from typing import Optional, List from typing import Optional, List
from database import get_db from database import get_db
@@ -136,9 +136,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id) q = q.filter(OrderItem.added_by == waiter_id)
items = q.all() items = q.all()
# Also fetch cancelled items for the same window/waiter
cq = db.query(OrderItem).filter(OrderItem.status == "cancelled")
if business_day_id:
cq = cq.join(Order, OrderItem.order_id == Order.id).filter(Order.business_day_id == business_day_id)
else:
cq = cq.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
if waiter_id:
cq = cq.filter(OrderItem.added_by == waiter_id)
cancelled_items = cq.all()
waiters_db = {u.id: u for u in db.query(User).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()} tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Track shifts for duration (hours worked)
shift_q = db.query(WaiterShift)
if business_day_id:
shift_q = shift_q.filter(WaiterShift.business_day_id == business_day_id)
else:
shift_q = shift_q.filter(WaiterShift.started_at >= start, WaiterShift.started_at < end)
if waiter_id:
shift_q = shift_q.filter(WaiterShift.waiter_id == waiter_id)
shifts_list = shift_q.all()
# hours worked per waiter
hours_by_waiter: dict[int, float] = {}
for s in shifts_list:
wid = s.waiter_id
end_time = s.ended_at or datetime.now(timezone.utc).replace(tzinfo=None)
delta = (end_time - s.started_at).total_seconds() / 3600.0
hours_by_waiter[wid] = hours_by_waiter.get(wid, 0.0) + delta
summary: dict[int, dict] = {} summary: dict[int, dict] = {}
for item in items: for item in items:
wid = item.added_by wid = item.added_by
@@ -150,6 +177,7 @@ def shift_orders_summary(
"waiter_name": wname, "waiter_name": wname,
"items": 0, "items": 0,
"total": 0.0, "total": 0.0,
"cancellations": 0,
"order_data": {}, "order_data": {},
} }
summary[wid]["items"] += item.quantity summary[wid]["items"] += item.quantity
@@ -173,10 +201,26 @@ def shift_orders_summary(
{"name": product_name, "quantity": item.quantity} {"name": product_name, "quantity": item.quantity}
) )
for item in cancelled_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,
"cancellations": 0,
"order_data": {},
}
summary[wid]["cancellations"] = summary[wid].get("cancellations", 0) + item.quantity
result = [] result = []
for entry in summary.values(): for entry in summary.values():
entry["orders"] = len(entry["order_data"]) entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values()) entry["order_data"] = list(entry["order_data"].values())
entry["hours_worked"] = round(hours_by_waiter.get(entry["waiter_id"], 0.0), 2)
result.append(entry) result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -1380,22 +1424,45 @@ def cancellations_log(
user: User = Depends(require_manager), user: User = Depends(require_manager),
): ):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled") q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
# Date filter on cancelled_at if available, otherwise fall back to added_at
if from_dt: if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt)) dt_from = datetime.fromisoformat(from_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at >= dt_from,
(OrderItem.cancelled_at == None) & (OrderItem.added_at >= dt_from),
))
if to_dt: if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt)) dt_to = datetime.fromisoformat(to_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at <= dt_to,
(OrderItem.cancelled_at == None) & (OrderItem.added_at <= dt_to),
))
if business_day_id: if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id) q = q.join(Order, Order.id == OrderItem.order_id).filter(Order.business_day_id == business_day_id)
if waiter_id: if waiter_id:
q = q.filter(OrderItem.cancelled_by == waiter_id) # Show items cancelled BY this waiter OR items that were on this waiter's order
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_by == waiter_id,
OrderItem.added_by == waiter_id,
))
items = q.order_by(OrderItem.added_at.desc()).all() items = q.order_by(OrderItem.added_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).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()} tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
orders_cache = {}
def _get_order(oid):
if oid not in orders_cache:
orders_cache[oid] = db.query(Order).filter(Order.id == oid).first()
return orders_cache[oid]
result = [] result = []
for item in items: for item in items:
order = db.query(Order).filter(Order.id == item.order_id).first() order = _get_order(item.order_id)
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else "" table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else ""
waiter = waiters_db.get(item.added_by) waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}" waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"

View File

@@ -75,6 +75,15 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
pay_data = compute_shift_pay(shift) pay_data = compute_shift_pay(shift)
# Count cancelled items attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
OrderItem.status == "cancelled",
OrderItem.added_by == shift.waiter_id,
OrderItem.added_at >= shift.started_at,
)
if shift.ended_at:
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
cancellations = cancelled_q.count()
return { return {
"id": shift.id, "id": shift.id,
"waiter_id": shift.waiter_id, "waiter_id": shift.waiter_id,
@@ -90,6 +99,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"hourly_rate_snapshot": shift.hourly_rate_snapshot, "hourly_rate_snapshot": shift.hourly_rate_snapshot,
"duration_hours": pay_data["duration_hours"], "duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"], "shift_pay": pay_data["shift_pay"],
"cancellations": cancellations,
# Phase 2E # Phase 2E
"counted_cash_end": shift.counted_cash_end, "counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy, "cash_discrepancy": shift.cash_discrepancy,

View File

@@ -16,6 +16,7 @@ class UserBase(BaseModel):
email: Optional[str] = None email: Optional[str] = None
# Phase 2B — payroll # Phase 2B — payroll
hourly_rate: Optional[float] = None hourly_rate: Optional[float] = None
can_cancel_orders: bool = False
class UserCreate(UserBase): class UserCreate(UserBase):
@@ -33,6 +34,7 @@ class UserUpdate(BaseModel):
note: Optional[str] = None note: Optional[str] = None
# Phase 2B — payroll # Phase 2B — payroll
hourly_rate: Optional[float] = None hourly_rate: Optional[float] = None
can_cancel_orders: Optional[bool] = None
class WaiterZoneOut(BaseModel): class WaiterZoneOut(BaseModel):

View File

@@ -1002,6 +1002,90 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = L
logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e) logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e)
def print_cancellation_ticket(order_id: int, item_ids: List[int]):
"""
Background task: print a ΑΚΥΡΩΣΗ ticket to the same printer zones
as the cancelled items. Connects to its own DB session.
"""
db: Session = SessionLocal()
try:
_do_print_cancellation(order_id, item_ids, db)
except Exception as e:
logger.exception("Unexpected error in print_cancellation_ticket for order %s: %s", order_id, e)
finally:
db.close()
def _do_print_cancellation(order_id: int, item_ids: List[int], db: Session):
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping cancellation ticket for order %s", order_id)
return
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
return
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
zone_map: dict[int, List[OrderItem]] = {}
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product and product.printer_zone_id:
zone_map.setdefault(product.printer_zone_id, []).append(item)
div = load_divider_style(db)
for printer_id, zone_items in zone_map.items():
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
if not printer:
continue
try:
p = _get_printer(printer.ip_address, printer.port)
_print_cancel_ticket(p, order, zone_items, db, printer.line_width, div)
p.close()
except Exception as e:
logger.error("Cancellation print failed for printer %s: %s", printer.name, e)
def _print_cancel_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int, div: str):
cfg = _load_print_settings(db)
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc))
def _cancel_banner():
p._raw(b'\x1b\x61\x01') # center
p._raw(b'\x1b\x21\x30') # double height+width
p._raw(b'\x1b\x45\x01') # bold on
_raw_text(p, "*** AKYPΩΣH ***\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
_cancel_banner()
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελια: #{order.id}\n")
_raw_text(p, f"Τραπεζι: {table_name}\n")
_raw_text(p, f"Ωρα: {now_str}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"#{item.product_id}"
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(name, item.quantity, line_width) + "\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
_cancel_banner()
p._raw(b'\n\n\n')
p.cut()
# ── Analytical report prints (products / categories / tables) ───────────────── # ── Analytical report prints (products / categories / tables) ─────────────────
def print_products_report(ip: str, port: int, report: dict): def print_products_report(ip: str, port: int, report: dict):

View File

@@ -203,6 +203,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
}) })
const [confirmAction, setConfirmAction] = useState(null) const [confirmAction, setConfirmAction] = useState(null)
const [printCancelConfirm, setPrintCancelConfirm] = useState(null) // { type: 'item'|'order', payload? }
const [printerId, setPrinterId] = useState('') const [printerId, setPrinterId] = useState('')
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username])) const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
@@ -219,13 +220,15 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
}) })
const cancelItem = useMutation({ const cancelItem = useMutation({
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`), mutationFn: ({ itemId, printCancellation }) =>
client.delete(`/api/orders/${orderId}/items/${itemId}?print_cancellation=${printCancellation}`),
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() }, onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα ακύρωσης'), onError: () => toast.error('Σφάλμα ακύρωσης'),
}) })
const cancelOrder = useMutation({ const cancelOrder = useMutation({
mutationFn: () => client.delete(`/api/orders/${orderId}`), mutationFn: ({ printCancellation }) =>
client.delete(`/api/orders/${orderId}?print_cancellation=${printCancellation}`),
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() }, onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'), onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
}) })
@@ -244,10 +247,28 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
function handleConfirm() { function handleConfirm() {
if (!confirmAction) return if (!confirmAction) return
if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload) const type = confirmAction.type
if (confirmAction.type === 'cancelOrder') cancelOrder.mutate() const payload = confirmAction.payload
if (confirmAction.type === 'closeOrder') closeOrder.mutate()
setConfirmAction(null) setConfirmAction(null)
if (type === 'cancelItem') {
setPrintCancelConfirm({ type: 'item', payload })
return
}
if (type === 'cancelOrder') {
setPrintCancelConfirm({ type: 'order' })
return
}
if (type === 'closeOrder') closeOrder.mutate()
}
function executeCancelWithPrint(printCancellation) {
if (!printCancelConfirm) return
if (printCancelConfirm.type === 'item') {
cancelItem.mutate({ itemId: printCancelConfirm.payload, printCancellation })
} else {
cancelOrder.mutate({ printCancellation })
}
setPrintCancelConfirm(null)
} }
const total = order ? orderTotal(order.items) : 0 const total = order ? orderTotal(order.items) : 0
@@ -475,6 +496,38 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onCancel={() => setConfirmAction(null)} onCancel={() => setConfirmAction(null)}
/> />
)} )}
{printCancelConfirm && (
<div style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 20, zIndex: 10,
}}>
<div style={{
background: 'white', borderRadius: 14, padding: '24px 28px',
maxWidth: 360, width: '100%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
textAlign: 'center',
}}>
<div style={{ fontSize: 28, marginBottom: 12 }}>🖨</div>
<p style={{ fontWeight: 700, fontSize: 16, margin: '0 0 6px', color: '#111315' }}>
Εκτύπωση ακύρωσης;
</p>
<p style={{ fontSize: 13, color: '#6b7280', margin: '0 0 20px', lineHeight: 1.5 }}>
Θέλετε να σταλεί ακυρωτικό ticket στον εκτυπωτή;
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button
onClick={() => executeCancelWithPrint(false)}
style={{ padding: '9px 18px', borderRadius: 8, border: '1px solid #e5e7eb', background: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
>Όχι, απλή ακύρωση</button>
<button
onClick={() => executeCancelWithPrint(true)}
style={{ padding: '9px 18px', borderRadius: 8, border: 'none', background: '#dc2626', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>Ναι, εκτύπωση</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -65,6 +65,7 @@ function ShiftSettingsSection() {
} }
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true' const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true' const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
return ( return (
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους"> <SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>} {isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
@@ -76,6 +77,12 @@ function ShiftSettingsSection() {
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους"> <OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} /> <Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
</OptionRow> </OptionRow>
<OptionRow
label="Ακυρώσεις Παραγγελιών από Σερβιτόρους"
description="Επιτρέπει σε εξουσιοδοτημένους σερβιτόρους να ακυρώνουν παραγγελίες ή αντικείμενα. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να ακυρώσει — ανεξάρτητα από τις ατομικές ρυθμίσεις."
>
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
</OptionRow>
</> </>
)} )}
</SectionCard> </SectionCard>

View File

@@ -263,7 +263,7 @@ const inputStyle = {
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box', fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
} }
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '' } const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '', can_cancel_orders: false }
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
export default function WaitersPage() { export default function WaitersPage() {
@@ -277,7 +277,7 @@ export default function WaitersPage() {
const [newAvatarFile, setNewAvatarFile] = useState(null) const [newAvatarFile, setNewAvatarFile] = useState(null)
const [newAvatarPreview, setNewAvatarPreview] = useState(null) const [newAvatarPreview, setNewAvatarPreview] = useState(null)
const [editModal, setEditModal] = useState(null) const [editModal, setEditModal] = useState(null)
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '' }) const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '', can_cancel_orders: false })
const avatarInputRef = useRef(null) const avatarInputRef = useRef(null)
const newAvatarInputRef = useRef(null) const newAvatarInputRef = useRef(null)
@@ -353,7 +353,7 @@ export default function WaitersPage() {
function openEdit(w) { function openEdit(w) {
setEditModal(w) setEditModal(w)
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' }) setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '', can_cancel_orders: !!w.can_cancel_orders })
} }
return ( return (
@@ -585,7 +585,7 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
</div> </div>
</div> </div>
{/* Column 3: Payroll + PIN */} {/* Column 3: Payroll + Permissions + PIN */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}> <div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
<div> <div>
<SectionLabel>Μισθοδοσία</SectionLabel> <SectionLabel>Μισθοδοσία</SectionLabel>
@@ -609,6 +609,23 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
</div> </div>
</FormField> </FormField>
</div> </div>
<div>
<SectionLabel>Δικαιώματα</SectionLabel>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!form.can_cancel_orders}
onChange={e => f('can_cancel_orders', e.target.checked)}
style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
/>
<div>
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>Ακύρωση παραγγελιών</span>
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>
Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
</p>
</div>
</label>
</div>
<SectionLabel>Κωδικός PIN</SectionLabel> <SectionLabel>Κωδικός PIN</SectionLabel>
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}> <p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε. Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
@@ -788,6 +805,24 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
</FormField> </FormField>
</div> </div>
<div>
<SectionLabel>Δικαιώματα</SectionLabel>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!form.can_cancel_orders}
onChange={e => f('can_cancel_orders', e.target.checked)}
style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
/>
<div>
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>Ακύρωση παραγγελιών</span>
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>
Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
</p>
</div>
</label>
</div>
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}> <div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p> <p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}> <p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>

View File

@@ -91,11 +91,13 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
<DataTable> <DataTable>
<THead> <THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH> <TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH> <TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
</THead> </THead>
<tbody> <tbody>
{orders.slice(0, 200).map(o => { {orders.slice(0, 200).map(o => {
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0) const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const isCancelled = o.status === 'cancelled' const isCancelled = o.status === 'cancelled'
return ( return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}> <TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
@@ -104,7 +106,12 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
<TD mono>{fmtDateTime(o.opened_at)}</TD> <TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{fmtDateTime(o.closed_at)}</TD> <TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD><StatusBadge status={o.status} /></TD> <TD><StatusBadge status={o.status} /></TD>
<TD mono align="right">{(o.items || []).length}</TD> <TD mono align="right">{activeItems.length}</TD>
<TD mono align="right">
{cancelledItems.length > 0
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD> <TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD align="right"> <TD align="right">
<button <button

View File

@@ -71,18 +71,25 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
: <DataTable> : <DataTable>
<THead> <THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH> <TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH> <TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
</THead> </THead>
<tbody> <tbody>
{orders.map(o => { {orders.map(o => {
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0) const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
return ( return (
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer"> <TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
<TD mono>#{o.id}</TD> <TD mono>#{o.id}</TD>
<TD>{o.table_name ?? o.table_id}</TD> <TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD> <TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD> <TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
<TD mono align="right">{(o.items || []).length}</TD> <TD mono align="right">{activeItems.length}</TD>
<TD mono align="right">
{cancelledItems.length > 0
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD> <TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD><StatusBadge status={o.status} /></TD> <TD><StatusBadge status={o.status} /></TD>
</TR> </TR>

View File

@@ -1,9 +1,12 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend, ComposedChart, Line,
} from 'recharts'
import client from '../../../api/client' import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar' import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives' import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable' import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens' import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
@@ -11,12 +14,53 @@ import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) } function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) } function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
const METRICS = [
{ key: 'orders', label: 'ΠΑΡΑΓΓΕΛΙΕΣ', color: '#60a5fa' },
{ key: 'items', label: 'ΕΙΔΗ', color: '#34d399' },
{ key: 'cancellations', label: 'ΑΚΥΡΩΣΕΙΣ', color: '#f87171' },
{ key: 'total', label: 'ΕΣΟΔΑ', color: '#a78bfa' },
{ key: 'hours_worked', label: 'ΩΡΕΣ', color: '#fb923c', rightAxis: true },
]
function fmtHours(h) {
if (h == null) return '—'
const hrs = Math.floor(h)
const mins = Math.round((h - hrs) * 60)
if (mins === 0) return `${hrs}ω`
return `${hrs}ω ${mins}λ`
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8,
padding: '10px 14px', fontSize: 12, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
minWidth: 160,
}}>
<div style={{ fontWeight: 700, color: '#1e293b', marginBottom: 6 }}>{label}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ display: 'flex', justifyContent: 'space-between', gap: 16, color: '#475569', marginBottom: 2 }}>
<span style={{ color: p.color, fontWeight: 600 }}>{p.name}</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>
{p.dataKey === 'total' ? fmtEUR(p.value)
: p.dataKey === 'hours_worked' ? fmtHours(p.value)
: fmtNum(p.value)}
</span>
</div>
))}
</div>
)
}
export default function Activity() { export default function Activity() {
const [waiterId, setWaiterId] = useState('all') const [waiterId, setWaiterId] = useState('all')
const [mode, setMode] = useState('range') const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo()) const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today()) const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all') const [businessDayId, setBusinessDayId] = useState('all')
const [activeMetrics, setActiveMetrics] = useState(new Set(['orders', 'items', 'cancellations', 'total']))
const [stacked, setStacked] = useState(false)
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 }) const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 }) const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
@@ -39,10 +83,28 @@ export default function Activity() {
const waiters = data?.waiters || [] const waiters = data?.waiters || []
const chartData = useMemo(() => waiters.map(w => ({ const chartData = useMemo(() => waiters.map(w => ({
name: (w.waiter_name || '').split(' ')[0], name: w.waiter_name || `#${w.waiter_id}`,
orders: w.orders, shortName: (w.waiter_name || '').split(' ')[0] || `#${w.waiter_id}`,
orders: w.orders || 0,
items: w.items || 0,
cancellations: w.cancellations || 0,
total: Math.round((w.total || 0) * 100) / 100,
hours_worked: w.hours_worked || 0,
})), [waiters]) })), [waiters])
function toggleMetric(key) {
setActiveMetrics(prev => {
const next = new Set(prev)
if (next.has(key)) { if (next.size > 1) next.delete(key) }
else next.add(key)
return next
})
}
const showRightAxis = activeMetrics.has('hours_worked')
const leftMetrics = METRICS.filter(m => !m.rightAxis && activeMetrics.has(m.key))
const hasRevenue = activeMetrics.has('total')
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={5} columns={7} showChart /></div> if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={5} columns={7} showChart /></div>
if (isError) return ( if (isError) return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
@@ -68,16 +130,106 @@ export default function Activity() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{waiters.length > 0 && ( {waiters.length > 0 && (
<Panel title="Παραγγελίες ανά Σερβιτόρο" subtitle="Συνολικές παραγγελίες στην επιλεγμένη περίοδο"> <Panel
<div style={{ height: 240 }}> title="Δραστηριότητα ανά Σερβιτόρο"
subtitle="Επιλέξτε μετρικές και τύπο γραφήματος"
right={
<div className="flex items-center gap-1 rounded-md border border-slate-200 p-0.5 bg-slate-50">
<button
onClick={() => setStacked(false)}
className={`rounded px-2.5 py-1 text-[11px] font-semibold transition-colors ${!stacked ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Ομαδοποίηση
</button>
<button
onClick={() => setStacked(true)}
className={`rounded px-2.5 py-1 text-[11px] font-semibold transition-colors ${stacked ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Στοίβαγμα
</button>
</div>
}
>
{/* Metric checkboxes */}
<div className="flex flex-wrap gap-2 mb-4">
{METRICS.map(m => {
const active = activeMetrics.has(m.key)
return (
<button
key={m.key}
onClick={() => toggleMetric(m.key)}
className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider transition-all"
style={{
borderColor: active ? m.color : '#e2e8f0',
background: active ? m.color + '18' : 'transparent',
color: active ? m.color : '#94a3b8',
}}
>
<span
style={{
width: 8, height: 8, borderRadius: '50%',
background: active ? m.color : '#cbd5e1', flexShrink: 0,
}}
/>
{m.label}
</button>
)
})}
</div>
<div style={{ height: 260 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}> <ComposedChart data={chartData} layout="vertical" margin={{ top: 4, right: showRightAxis ? 60 : 24, bottom: 4, left: 8 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" /> <CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} /> <XAxis
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={80} /> type="number"
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} /> tick={{ fontSize: 11, fill: '#94a3b8' }}
<Bar dataKey="orders" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={18} /> stroke="#cbd5e1" axisLine={false} tickLine={false}
</BarChart> tickFormatter={v => hasRevenue && leftMetrics.length === 1 ? `${v}` : v}
/>
{showRightAxis && (
<XAxis
xAxisId="right"
type="number"
orientation="top"
tick={{ fontSize: 11, fill: '#fb923c' }}
stroke="#fed7aa" axisLine={false} tickLine={false}
tickFormatter={v => `${v}ω`}
/>
)}
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 12, fill: '#475569' }}
stroke="#cbd5e1" axisLine={false} tickLine={false}
width={90}
tickFormatter={v => v.length > 12 ? v.slice(0, 11) + '…' : v}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f1f5f9' }} />
{METRICS.filter(m => !m.rightAxis && activeMetrics.has(m.key)).map(m => (
<Bar
key={m.key}
dataKey={m.key}
name={m.label}
fill={m.color}
radius={stacked ? 0 : [0, 3, 3, 0]}
barSize={stacked ? 20 : 10}
stackId={stacked ? 'stack' : undefined}
/>
))}
{activeMetrics.has('hours_worked') && (
<Bar
key="hours_worked"
xAxisId="right"
dataKey="hours_worked"
name="ΩΡΕΣ"
fill="#fb923c"
radius={[0, 3, 3, 0]}
barSize={6}
opacity={0.7}
/>
)}
</ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</Panel> </Panel>
@@ -93,14 +245,22 @@ export default function Activity() {
<TH>Σερβιτόρος</TH> <TH>Σερβιτόρος</TH>
<TH align="right">Παραγγελίες</TH> <TH align="right">Παραγγελίες</TH>
<TH align="right">Είδη</TH> <TH align="right">Είδη</TH>
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Ώρες</TH>
<TH align="right">Συνολική Αξία</TH> <TH align="right">Συνολική Αξία</TH>
</THead> </THead>
<tbody> <tbody>
{waiters.sort((a, b) => b.total - a.total).map(w => ( {[...waiters].sort((a, b) => (b.total || 0) - (a.total || 0)).map(w => (
<TR key={w.waiter_id} striped> <TR key={w.waiter_id} striped>
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD> <TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
<TD mono align="right">{fmtNum(w.orders)}</TD> <TD mono align="right">{fmtNum(w.orders)}</TD>
<TD mono align="right">{fmtNum(w.items)}</TD> <TD mono align="right">{fmtNum(w.items)}</TD>
<TD mono align="right">
{w.cancellations > 0
? <span className="text-red-500 font-semibold">{fmtNum(w.cancellations)}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">{fmtHours(w.hours_worked)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(w.total)}</TD> <TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(w.total)}</TD>
</TR> </TR>
))} ))}

View File

@@ -125,6 +125,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<TH align="right">Εισπράχθηκαν</TH> <TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Οφείλει</TH> <TH align="right">Οφείλει</TH>
<TH align="right">Ταμείο</TH> <TH align="right">Ταμείο</TH>
<TH align="right">Ακυρώσεις</TH>
<TH>Κατάσταση</TH> <TH>Κατάσταση</TH>
<TH className="w-28" /> <TH className="w-28" />
</THead> </THead>
@@ -177,6 +178,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<span className="text-slate-400 text-[11px]"></span> <span className="text-slate-400 text-[11px]"></span>
)} )}
</TD> </TD>
<TD mono align="right">
{s.cancellations > 0
? <span className="text-red-600 font-semibold">{s.cancellations}</span>
: <span className="text-slate-300"></span>}
</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD> <TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right"> <TD align="right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -201,7 +207,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
</TR>, </TR>,
isOpen && ( isOpen && (
<tr key={`${s.id}-detail`}> <tr key={`${s.id}-detail`}>
<td colSpan={12} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3"> <td colSpan={13} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<div className="flex items-center gap-4 text-[12px] text-slate-500"> <div className="flex items-center gap-4 text-[12px] text-slate-500">
<span> <span>
{'Εργάσιμη Μέρα: '} {'Εργάσιμη Μέρα: '}

View File

@@ -83,69 +83,104 @@ function SplitModal({ item, onConfirm, onClose }) {
// ─── Item action modal (long-press) ────────────────────────────────────────── // ─── Item action modal (long-press) ──────────────────────────────────────────
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) { function ItemActionModal({ target, onOrderAgain, onSplit, onMoveToTable, onCancel, canCancel, onClose }) {
const { items, singleStacked, multiSelect } = target const { items, singleStacked, multiSelect } = target
const label = multiSelect const label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα` ? `${items.length} αντικείμενα επιλεγμένα`
: items[0]?.product?.name || `#${items[0]?.product_id}` : items[0]?.product?.name || `#${items[0]?.product_id}`
const actionRows = [
{
key: 'order_again',
color: '#22c55e',
iconBg: 'rgba(34,197,94,0.15)',
label: 'Παραγγελία ξανά',
sub: 'Προσθήκη στο νέο καλάθι',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 4v6h6M23 20v-6h-6" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onOrderAgain,
enabled: true,
},
{
key: 'move',
color: '#f97316',
iconBg: 'rgba(249,115,22,0.15)',
label: 'Μεταφορά σε άλλο τραπέζι',
sub: 'Μεταφορά επιλεγμένων αντικειμένων',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14M12 5l7 7-7 7" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onMoveToTable,
enabled: true,
},
...(singleStacked && !multiSelect ? [{
key: 'split',
color: '#60a5fa',
iconBg: 'rgba(96,165,250,0.15)',
label: 'Διαχωρισμός',
sub: 'Χώρισμα σε δύο γραμμές',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onSplit,
enabled: true,
}] : []),
{
key: 'cancel',
color: canCancel ? '#ef4444' : '#6b7280',
iconBg: canCancel ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)',
label: 'Ακύρωση παραγγελίας',
sub: canCancel ? 'Ακύρωση επιλεγμένων αντικειμένων' : 'Δεν επιτρέπεται η ακύρωση',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2" strokeLinecap="round"/>
</svg>
),
onClick: canCancel ? onCancel : null,
enabled: canCancel,
},
]
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}> <div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
<div className="modal-handle" /> <div className="modal-handle" />
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p> <p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
{actionRows.map((a, i) => (
<button <button
onClick={onOrderAgain} key={a.key}
onClick={a.onClick ? a.onClick : undefined}
disabled={!a.enabled}
style={{ style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14, width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none', padding: '16px 4px', background: 'none', border: 'none',
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none', borderBottom: i < actionRows.length - 1 ? '1px solid var(--border)' : 'none',
cursor: 'pointer', textAlign: 'left', cursor: a.enabled ? 'pointer' : 'not-allowed',
opacity: a.enabled ? 1 : 0.45, textAlign: 'left',
}} }}
> >
<span style={{ <span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0, width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(245,158,11,0.15)', background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>{a.icon}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
<div> <div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div> <div style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div> <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{a.sub}</div>
</div> </div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span> {a.enabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>}
</button> </button>
))}
{singleStacked && !multiSelect && (
<button
onClick={onSplit}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(96,165,250,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
)}
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}> <button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
Άκυρο Άκυρο
@@ -157,7 +192,16 @@ function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
// ─── Actions top sheet ──────────────────────────────────────────────────────── // ─── Actions top sheet ────────────────────────────────────────────────────────
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) { function CancelIcon({ width = 20, height = 20 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
)
}
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis, onCancelOrder, canCancel }) {
const hasOrder = !!order const hasOrder = !!order
const actions = [ const actions = [
{ Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' }, { Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
@@ -165,6 +209,8 @@ function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' }, { Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' }, { Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' }, { Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
{ Icon: CancelIcon, label: 'Ακύρωση Παραγγελίας', sub: canCancel ? 'Ακύρωση ολόκληρης παραγγελίας' : 'Δεν επιτρέπεται η ακύρωση',
onClick: (hasOrder && canCancel) ? onCancelOrder : null, color: (hasOrder && canCancel) ? '#ef4444' : '#6b7280', iconBg: (hasOrder && canCancel) ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)' },
] ]
return ( return (
@@ -437,6 +483,32 @@ function PrintSynopsisPicker({ orderId, onClose }) {
// ─── Pay confirm modal ──────────────────────────────────────────────────────── // ─── Pay confirm modal ────────────────────────────────────────────────────────
function CancelConfirmModal({ itemCount, onConfirm, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">Ακύρωση παραγγελίας;</h2>
<p style={{ color: '#94a3b8', textAlign: 'center', marginBottom: 24, fontSize: 14 }}>
{itemCount === null
? 'Ολόκληρη η παραγγελία θα ακυρωθεί οριστικά.'
: `${itemCount} αντικείμενο${itemCount !== 1 ? 'α' : ''} θα ακυρωθ${itemCount !== 1 ? 'ούν' : 'εί'} οριστικά.`}
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Άκυρο</button>
<button
onClick={onConfirm}
style={{
flex: 1, height: 48, borderRadius: 12, border: 'none',
background: '#dc2626', color: 'white', fontSize: 15, fontWeight: 700, cursor: 'pointer',
}}
>Ακύρωση </button>
</div>
</div>
</div>
)
}
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) { function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
const payTotal = activeItems const payTotal = activeItems
.filter(i => payIds.includes(i.id)) .filter(i => payIds.includes(i.id))
@@ -504,6 +576,8 @@ export default function TableDetailPage() {
const [actionDataLoading, setActionDataLoading] = useState(false) const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null) const [splitItem, setSplitItem] = useState(null)
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool } const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
const [canCancel, setCanCancel] = useState(false)
const [cancelConfirm, setCancelConfirm] = useState(null) // null | { mode: 'items', ids: [...] } | { mode: 'order' }
const scrollRef = useRef(null) const scrollRef = useRef(null)
@@ -533,6 +607,12 @@ export default function TableDetailPage() {
useEffect(() => { load() }, [tableId]) useEffect(() => { load() }, [tableId])
useEffect(() => {
client.get('/api/orders/cancel-permissions')
.then(r => setCanCancel(!!r.data.can_cancel))
.catch(() => {})
}, [])
// Handle ?action= param from table list long-press quick actions // Handle ?action= param from table list long-press quick actions
useEffect(() => { useEffect(() => {
const action = searchParams.get('action') const action = searchParams.get('action')
@@ -624,6 +704,28 @@ export default function TableDetailPage() {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
} }
async function executeCancelItems(itemIds) {
setCancelConfirm(null)
try {
await client.post(`/api/orders/${order.id}/cancel-items`, { item_ids: itemIds, print_cancellation: true })
setSelectedIds([])
setItemActionTarget(null)
await load()
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης')
}
}
async function executeCancelOrder() {
setCancelConfirm(null)
try {
await client.delete(`/api/orders/${order.id}?print_cancellation=true`)
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης παραγγελίας')
}
}
function selectAll() { function selectAll() {
const allActive = activeItems.map(i => i.id) const allActive = activeItems.map(i => i.id)
const allSelected = allActive.every(id => selectedIds.includes(id)) const allSelected = allActive.every(id => selectedIds.includes(id))
@@ -892,7 +994,7 @@ export default function TableDetailPage() {
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
padding: '10px 12px 24px', padding: '10px 12px 24px',
}}> }}>
{/* Clear selection */} {/* CLEAR */}
<button <button
onClick={() => setSelectedIds([])} onClick={() => setSelectedIds([])}
style={{ style={{
@@ -901,13 +1003,11 @@ export default function TableDetailPage() {
borderRadius: 999, borderRadius: 999,
background: 'rgba(239,68,68,0.18)', color: '#fca5a5', background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
border: 'none', cursor: 'pointer', border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}} }}
> >CLEAR</button>
καθ. επιλ.
</button>
{/* Select all — hidden once everything is already selected */} {/* ALL */}
{!allActiveSelected && ( {!allActiveSelected && (
<button <button
onClick={selectAll} onClick={selectAll}
@@ -917,10 +1017,10 @@ export default function TableDetailPage() {
borderRadius: 999, borderRadius: 999,
background: 'rgba(34,197,94,0.18)', color: '#86efac', background: 'rgba(34,197,94,0.18)', color: '#86efac',
border: 'none', cursor: 'pointer', border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}} }}
> >
όλα ALL
<span style={{ <span style={{
background: 'rgba(34,197,94,0.35)', color: '#86efac', background: 'rgba(34,197,94,0.35)', color: '#86efac',
borderRadius: 999, fontSize: 11, fontWeight: 700, borderRadius: 999, fontSize: 11, fontWeight: 700,
@@ -929,26 +1029,25 @@ export default function TableDetailPage() {
</button> </button>
)} )}
{/* Transfer items */} {/* ACTIONS — opens item action modal */}
<button <button
onClick={() => { onClick={() => {
setAllTables([]) const items = activeItems.filter(i => selectedIds.includes(i.id))
setAllOrders([]) const singleStacked = items.length === 1 && items[0].quantity > 1
loadActionData() setItemActionTarget({ items, singleStacked, multiSelect: items.length > 1 })
setActionsMode('move_items')
}} }}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px', height: 36, padding: '0 16px',
borderRadius: 999, borderRadius: 999,
background: 'rgba(96,165,250,0.18)', color: '#93c5fd', background: 'rgba(148,163,184,0.18)', color: '#cbd5e1',
border: 'none', cursor: 'pointer', border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}} }}
> >
μεταφορά ACTIONS
<span style={{ <span style={{
background: 'rgba(96,165,250,0.35)', color: '#93c5fd', background: 'rgba(148,163,184,0.25)', color: '#cbd5e1',
borderRadius: 999, fontSize: 11, fontWeight: 700, borderRadius: 999, fontSize: 11, fontWeight: 700,
padding: '1px 6px', padding: '1px 6px',
}}>{selectedIds.length}</span> }}>{selectedIds.length}</span>
@@ -1020,10 +1119,11 @@ export default function TableDetailPage() {
</div> </div>
)} )}
{/* Item action modal (long-press) */} {/* Item action modal (long-press or ACTIONS pill) */}
{itemActionTarget && ( {itemActionTarget && (
<ItemActionModal <ItemActionModal
target={itemActionTarget} target={itemActionTarget}
canCancel={canCancel}
onOrderAgain={() => { onOrderAgain={() => {
const items = itemActionTarget.items const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify( sessionStorage.setItem('orderAgainItems', JSON.stringify(
@@ -1042,10 +1142,34 @@ export default function TableDetailPage() {
setSplitItem(itemActionTarget.items[0]) setSplitItem(itemActionTarget.items[0])
setItemActionTarget(null) setItemActionTarget(null)
}} }}
onMoveToTable={() => {
setItemActionTarget(null)
setAllTables([])
setAllOrders([])
loadActionData()
setActionsMode('move_items')
}}
onCancel={() => {
const ids = itemActionTarget.items.map(i => i.id)
setItemActionTarget(null)
setCancelConfirm({ mode: 'items', ids })
}}
onClose={() => setItemActionTarget(null)} onClose={() => setItemActionTarget(null)}
/> />
)} )}
{/* Cancel confirmation */}
{cancelConfirm && (
<CancelConfirmModal
itemCount={cancelConfirm.mode === 'items' ? cancelConfirm.ids.length : null}
onConfirm={() => {
if (cancelConfirm.mode === 'items') executeCancelItems(cancelConfirm.ids)
else executeCancelOrder()
}}
onClose={() => setCancelConfirm(null)}
/>
)}
{/* Split stepper modal */} {/* Split stepper modal */}
{splitItem && ( {splitItem && (
<SplitModal <SplitModal
@@ -1097,12 +1221,14 @@ export default function TableDetailPage() {
<ActionsSheet <ActionsSheet
order={order} order={order}
tableId={tableId} tableId={tableId}
canCancel={canCancel}
onClose={() => setShowActions(false)} onClose={() => setShowActions(false)}
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }} onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
onMerge={() => { setShowActions(false); setActionsMode('merge') }} onMerge={() => { setShowActions(false); setActionsMode('merge') }}
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }} onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }} onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }} onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
onCancelOrder={() => { setShowActions(false); setCancelConfirm({ mode: 'order' }) }}
/> />
)} )}

View File

@@ -79,7 +79,7 @@ const QUICK_ACTIONS = [
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' }, { Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
] ]
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) { function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction, onCancelOrder, canCancel }) {
const tableName = table.label || `T${table.number}` const tableName = table.label || `T${table.number}`
const activeItems = order?.items?.filter(i => i.status === 'active') || [] const activeItems = order?.items?.filter(i => i.status === 'active') || []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0) const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
@@ -147,7 +147,7 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{ <button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
display: 'flex', alignItems: 'center', gap: 14, display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0', background: 'none', border: 'none', padding: '12px 0', background: 'none', border: 'none',
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none', borderBottom: '1px solid var(--border)',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left', opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}> }}>
@@ -159,6 +159,30 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
</button> </button>
) )
})} })}
{/* Cancel order */}
{(() => {
const hasActiveItems = activeItems.length > 0
const disabled = !order || !hasActiveItems
const colour = canCancel && !disabled ? '#ef4444' : 'var(--muted)'
return (
<button
disabled={!canCancel || disabled}
onClick={() => { if (canCancel && !disabled) onCancelOrder() }}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0', background: 'none', border: 'none',
cursor: canCancel && !disabled ? 'pointer' : 'not-allowed',
opacity: canCancel && !disabled ? 1 : 0.35, textAlign: 'left',
}}>
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: 'rgba(239,68,68,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: colour }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</span>
<span style={{ fontSize: 15, fontWeight: 600, color: colour }}>Ακύρωση Παραγγελίας</span>
</button>
)
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -358,6 +382,8 @@ export default function TableListPage() {
const [quickModal, setQuickModal] = useState(null) const [quickModal, setQuickModal] = useState(null)
const [emergencyPayModal, setEmergencyPayModal] = useState(null) const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set()) const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
const [canCancel, setCanCancel] = useState(false)
const [cancelConfirm, setCancelConfirm] = useState(null)
// pull-to-refresh state // pull-to-refresh state
const [pulling, setPulling] = useState(false) const [pulling, setPulling] = useState(false)
@@ -433,6 +459,19 @@ export default function TableListPage() {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
useEffect(() => {
client.get('/api/orders/cancel-permissions').then(r => setCanCancel(r.data?.can_cancel === true)).catch(() => {})
}, [])
async function executeCancelOrder(orderId) {
try {
await client.delete(`/api/orders/${orderId}?print_cancellation=true`)
setCancelConfirm(null)
setQuickModal(null)
load()
} catch {}
}
// ── SSE live updates ─────────────────────────────────────────────────────── // ── SSE live updates ───────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (isEmergency) return if (isEmergency) return
@@ -736,12 +775,28 @@ export default function TableListPage() {
table={quickModal.table} table={quickModal.table}
order={quickModal.order} order={quickModal.order}
flags={quickModal.flags} flags={quickModal.flags}
canCancel={canCancel}
onClose={() => setQuickModal(null)} onClose={() => setQuickModal(null)}
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)} onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
onAction={(key) => handleQuickAction(quickModal.table.id, key)} onAction={(key) => handleQuickAction(quickModal.table.id, key)}
onCancelOrder={() => setCancelConfirm({ orderId: quickModal.order?.id })}
/> />
)} )}
{cancelConfirm && (
<div className="modal-overlay" onClick={() => setCancelConfirm(null)}>
<div style={{ width: '100%', maxWidth: 400, margin: '0 auto', background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '24px 20px 32px' }} onClick={e => e.stopPropagation()}>
<div className="modal-handle" style={{ marginBottom: 16 }} />
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', marginBottom: 8 }}>Ακύρωση Παραγγελίας;</p>
<p style={{ fontSize: 14, color: 'var(--muted)', marginBottom: 20 }}>Όλα τα ενεργά αντικείμενα θα ακυρωθούν και θα εκτυπωθεί απόδειξη ακύρωσης.</p>
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={() => setCancelConfirm(null)}>Ακύρωση</button>
<button className="btn" style={{ flex: 1, background: '#ef4444', color: '#fff' }} onClick={() => executeCancelOrder(cancelConfirm.orderId)}>Επιβεβαίωση</button>
</div>
</div>
</div>
)}
{emergencyPayModal && ( {emergencyPayModal && (
<EmergencyPayModal <EmergencyPayModal
table={emergencyPayModal.table} table={emergencyPayModal.table}