diff --git a/local_backend/main.py b/local_backend/main.py
index 97c04d0..b9dc207 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -408,6 +408,9 @@ def _run_migrations():
)""",
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
"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:
try:
diff --git a/local_backend/models/user.py b/local_backend/models/user.py
index 8695eeb..f4448f9 100644
--- a/local_backend/models/user.py
+++ b/local_backend/models/user.py
@@ -26,6 +26,8 @@ class User(Base):
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Phase 2B — payroll
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_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py
index f0d0be0..9723155 100644
--- a/local_backend/routers/orders.py
+++ b/local_backend/routers/orders.py
@@ -42,12 +42,20 @@ class MoveItemsRequest(BaseModel):
target_order_id: int
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
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:
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
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"]
+
+@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])
def list_orders(
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)
-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()
if not item:
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.cancelled_by = user.id
+ item.cancelled_at = now
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
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")
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)
-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()
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")
+
now = datetime.now(timezone.utc)
- # Cancel all still-active items
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
@@ -561,18 +626,76 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
+ item.cancelled_by = user.id
+ item.cancelled_at = now
cancelled_item_ids.append(item.id)
order.status = "cancelled"
order.closed_at = now
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,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit()
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")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py
index b463e41..ee85ca1 100644
--- a/local_backend/routers/reports.py
+++ b/local_backend/routers/reports.py
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
-from datetime import date, datetime, timedelta
+from datetime import date, datetime, timedelta, timezone
from typing import Optional, List
from database import get_db
@@ -136,9 +136,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id)
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()}
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] = {}
for item in items:
wid = item.added_by
@@ -150,6 +177,7 @@ def shift_orders_summary(
"waiter_name": wname,
"items": 0,
"total": 0.0,
+ "cancellations": 0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
@@ -173,10 +201,26 @@ def shift_orders_summary(
{"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 = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
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)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -1380,22 +1424,45 @@ def cancellations_log(
user: User = Depends(require_manager),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
+
+ # Date filter on cancelled_at if available, otherwise fall back to added_at
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:
- 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:
- 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:
- 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()
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()}
+ 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 = []
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 "—"
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"
diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py
index ecffaba..9877ec0 100644
--- a/local_backend/routers/shifts.py
+++ b/local_backend/routers/shifts.py
@@ -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}"
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)
+ # 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 {
"id": shift.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,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
+ "cancellations": cancellations,
# Phase 2E
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,
diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py
index 4c0ecdd..c410066 100644
--- a/local_backend/schemas/user.py
+++ b/local_backend/schemas/user.py
@@ -16,6 +16,7 @@ class UserBase(BaseModel):
email: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
+ can_cancel_orders: bool = False
class UserCreate(UserBase):
@@ -33,6 +34,7 @@ class UserUpdate(BaseModel):
note: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
+ can_cancel_orders: Optional[bool] = None
class WaiterZoneOut(BaseModel):
diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py
index 44ca7f8..e9eb91e 100644
--- a/local_backend/services/printer_service.py
+++ b/local_backend/services/printer_service.py
@@ -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)
+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) ─────────────────
def print_products_report(ip: str, port: int, report: dict):
diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx
index 0b7a4cc..1a8aab8 100644
--- a/manager_dashboard/src/pages/DashboardPage.jsx
+++ b/manager_dashboard/src/pages/DashboardPage.jsx
@@ -203,6 +203,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
})
const [confirmAction, setConfirmAction] = useState(null)
+ const [printCancelConfirm, setPrintCancelConfirm] = useState(null) // { type: 'item'|'order', payload? }
const [printerId, setPrinterId] = useState('')
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({
- 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() },
onError: () => toast.error('Σφάλμα ακύρωσης'),
})
const cancelOrder = useMutation({
- mutationFn: () => client.delete(`/api/orders/${orderId}`),
+ mutationFn: ({ printCancellation }) =>
+ client.delete(`/api/orders/${orderId}?print_cancellation=${printCancellation}`),
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
})
@@ -244,10 +247,28 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
function handleConfirm() {
if (!confirmAction) return
- if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
- if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
- if (confirmAction.type === 'closeOrder') closeOrder.mutate()
+ const type = confirmAction.type
+ const payload = confirmAction.payload
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
@@ -475,6 +496,38 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onCancel={() => setConfirmAction(null)}
/>
)}
+
+ {printCancelConfirm && (
+
+
+
🖨️
+
+ Εκτύπωση ακύρωσης;
+
+
+ Θέλετε να σταλεί ακυρωτικό ticket στον εκτυπωτή;
+
+
+ executeCancelWithPrint(false)}
+ style={{ padding: '9px 18px', borderRadius: 8, border: '1px solid #e5e7eb', background: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
+ >Όχι, απλή ακύρωση
+ executeCancelWithPrint(true)}
+ style={{ padding: '9px 18px', borderRadius: 8, border: 'none', background: '#dc2626', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
+ >Ναι, εκτύπωση
+
+
+
+ )}
)
}
diff --git a/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx b/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
index cecc3b2..c6cf6a3 100644
--- a/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
+++ b/manager_dashboard/src/pages/Settings/tabs/OperationTab.jsx
@@ -63,8 +63,9 @@ function ShiftSettingsSection() {
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
- const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
- const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
+ const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
+ const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
+ const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
return (
{isLoading && Φόρτωση…
}
@@ -76,6 +77,12 @@ function ShiftSettingsSection() {
toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
+
+ toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
+
>
)}
diff --git a/manager_dashboard/src/pages/StaffTab.jsx b/manager_dashboard/src/pages/StaffTab.jsx
index dd1630d..86e6478 100644
--- a/manager_dashboard/src/pages/StaffTab.jsx
+++ b/manager_dashboard/src/pages/StaffTab.jsx
@@ -263,7 +263,7 @@ const inputStyle = {
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 ─────────────────────────────────────────────────────────────────
export default function WaitersPage() {
@@ -277,7 +277,7 @@ export default function WaitersPage() {
const [newAvatarFile, setNewAvatarFile] = useState(null)
const [newAvatarPreview, setNewAvatarPreview] = 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 newAvatarInputRef = useRef(null)
@@ -353,7 +353,7 @@ export default function WaitersPage() {
function openEdit(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 (
@@ -585,7 +585,7 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
- {/* Column 3: Payroll + PIN */}
+ {/* Column 3: Payroll + Permissions + PIN */}
Μισθοδοσία
@@ -609,6 +609,23 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
+
+
Δικαιώματα
+
+ f('can_cancel_orders', e.target.checked)}
+ style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
+ />
+
+
Ακύρωση παραγγελιών
+
+ Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
+
+
+
+
Κωδικός PIN
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
@@ -788,6 +805,24 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
+
+
Δικαιώματα
+
+ f('can_cancel_orders', e.target.checked)}
+ style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
+ />
+
+
Ακύρωση παραγγελιών
+
+ Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
+
+
+
+
+
Κωδικός PIN
diff --git a/manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx b/manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
index 359a41f..575171b 100644
--- a/manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
@@ -91,11 +91,13 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
# Τραπέζι Άνοιξε Έκλεισε
- Κατάσταση Είδη Σύνολο
+ Κατάσταση Είδη Ακυρώσεις Σύνολο
{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'
return (
@@ -104,7 +106,12 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
{fmtDateTime(o.opened_at)}
{fmtDateTime(o.closed_at)}
- {(o.items || []).length}
+ {activeItems.length}
+
+ {cancelledItems.length > 0
+ ? {cancelledItems.length}
+ : — }
+
{fmtEUR(total)}
# Τραπέζι Άνοιξε Έκλεισε
- Είδη Σύνολο Κατάσταση
+ Είδη Ακυρώσεις Σύνολο Κατάσταση
{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 (
setDrillOrder(o)} className="cursor-pointer">
#{o.id}
{o.table_name ?? o.table_id}
{fmtDateTime(o.opened_at)}
{o.closed_at ? fmtDateTime(o.closed_at) : '—'}
- {(o.items || []).length}
+ {activeItems.length}
+
+ {cancelledItems.length > 0
+ ? {cancelledItems.length}
+ : — }
+
{fmtEUR(total)}
diff --git a/manager_dashboard/src/pages/reports/staff/Activity.jsx b/manager_dashboard/src/pages/reports/staff/Activity.jsx
index 07cafe4..bc0941c 100644
--- a/manager_dashboard/src/pages/reports/staff/Activity.jsx
+++ b/manager_dashboard/src/pages/reports/staff/Activity.jsx
@@ -1,9 +1,12 @@
import { useState, useMemo } from 'react'
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 { 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 SkeletonTable from '../shared/SkeletonTable'
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 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 (
+
+
{label}
+ {payload.map(p => (
+
+ {p.name}
+
+ {p.dataKey === 'total' ? fmtEUR(p.value)
+ : p.dataKey === 'hours_worked' ? fmtHours(p.value)
+ : fmtNum(p.value)}
+
+
+ ))}
+
+ )
+}
+
export default function Activity() {
const [waiterId, setWaiterId] = useState('all')
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
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: 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 chartData = useMemo(() => waiters.map(w => ({
- name: (w.waiter_name || '').split(' ')[0],
- orders: w.orders,
+ name: w.waiter_name || `#${w.waiter_id}`,
+ 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])
+ 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
if (isError) return (
@@ -68,16 +130,106 @@ export default function Activity() {
{waiters.length > 0 && (
-
-
+
+ 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'}`}
+ >
+ Ομαδοποίηση
+
+ 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'}`}
+ >
+ Στοίβαγμα
+
+
+ }
+ >
+ {/* Metric checkboxes */}
+
+ {METRICS.map(m => {
+ const active = activeMetrics.has(m.key)
+ return (
+ 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',
+ }}
+ >
+
+ {m.label}
+
+ )
+ })}
+
+
+
-
+
-
-
- } cursor={{ fill: '#f1f5f9' }} />
-
-
+ hasRevenue && leftMetrics.length === 1 ? `€${v}` : v}
+ />
+ {showRightAxis && (
+ `${v}ω`}
+ />
+ )}
+ v.length > 12 ? v.slice(0, 11) + '…' : v}
+ />
+ } cursor={{ fill: '#f1f5f9' }} />
+ {METRICS.filter(m => !m.rightAxis && activeMetrics.has(m.key)).map(m => (
+
+ ))}
+ {activeMetrics.has('hours_worked') && (
+
+ )}
+
@@ -93,14 +245,22 @@ export default function Activity() {
Σερβιτόρος
Παραγγελίες
Είδη
+
Ακυρώσεις
+
Ώρες
Συνολική Αξία
- {waiters.sort((a, b) => b.total - a.total).map(w => (
+ {[...waiters].sort((a, b) => (b.total || 0) - (a.total || 0)).map(w => (
{fmtNum(w.orders)}
{fmtNum(w.items)}
+
+ {w.cancellations > 0
+ ? {fmtNum(w.cancellations)}
+ : — }
+
+ {fmtHours(w.hours_worked)}
{fmtEUR(w.total)}
))}
diff --git a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
index 5faf824..ae1e88a 100644
--- a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
+++ b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
@@ -125,6 +125,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
Εισπράχθηκαν
Οφείλει
Ταμείο
+ Ακυρώσεις
Κατάσταση
@@ -177,6 +178,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
—
)}
+
+ {s.cancellations > 0
+ ? {s.cancellations}
+ : — }
+
@@ -201,7 +207,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
,
isOpen && (
-
+
{'Εργάσιμη Μέρα: '}
diff --git a/waiter_pwa/src/pages/TableDetailPage.jsx b/waiter_pwa/src/pages/TableDetailPage.jsx
index 3b23ee8..9d1ce99 100644
--- a/waiter_pwa/src/pages/TableDetailPage.jsx
+++ b/waiter_pwa/src/pages/TableDetailPage.jsx
@@ -83,69 +83,104 @@ function SplitModal({ item, onConfirm, onClose }) {
// ─── 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 label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα`
: 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: (
+
+
+
+
+ ),
+ onClick: onOrderAgain,
+ enabled: true,
+ },
+ {
+ key: 'move',
+ color: '#f97316',
+ iconBg: 'rgba(249,115,22,0.15)',
+ label: 'Μεταφορά σε άλλο τραπέζι',
+ sub: 'Μεταφορά επιλεγμένων αντικειμένων',
+ icon: (
+
+
+
+ ),
+ onClick: onMoveToTable,
+ enabled: true,
+ },
+ ...(singleStacked && !multiSelect ? [{
+ key: 'split',
+ color: '#60a5fa',
+ iconBg: 'rgba(96,165,250,0.15)',
+ label: 'Διαχωρισμός',
+ sub: 'Χώρισμα σε δύο γραμμές',
+ icon: (
+
+
+
+ ),
+ 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: (
+
+
+
+
+ ),
+ onClick: canCancel ? onCancel : null,
+ enabled: canCancel,
+ },
+ ]
+
return (
e.stopPropagation()} style={{ gap: 0 }}>
{label}
-
-
-
-
-
-
-
-
-
Παραγγελία ξανά
-
Προσθήκη στο νέο καλάθι
-
- ›
-
-
- {singleStacked && !multiSelect && (
+ {actionRows.map((a, i) => (
-
-
-
-
+ }}>{a.icon}
-
Διαχωρισμός
-
Χώρισμα σε δύο γραμμές
+
{a.label}
+
{a.sub}
- ›
+ {a.enabled && › }
- )}
+ ))}
Άκυρο
@@ -157,14 +192,25 @@ function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
// ─── Actions top sheet ────────────────────────────────────────────────────────
-function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
+function CancelIcon({ width = 20, height = 20 }) {
+ return (
+
+
+
+
+ )
+}
+
+function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis, onCancelOrder, canCancel }) {
const hasOrder = !!order
const actions = [
- { Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
- { Icon: MergeIcon, label: 'Συγχώνευση Τραπεζιού', sub: 'Συγχώνευση με άλλο τραπέζι', onClick: hasOrder ? onMerge : null, color: '#6099db', iconBg: 'rgba(96,165,250,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: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
+ { Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
+ { Icon: MergeIcon, label: 'Συγχώνευση Τραπεζιού', sub: 'Συγχώνευση με άλλο τραπέζι', onClick: hasOrder ? onMerge : null, color: '#6099db', iconBg: 'rgba(96,165,250,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: 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 (
@@ -437,6 +483,32 @@ function PrintSynopsisPicker({ orderId, onClose }) {
// ─── Pay confirm modal ────────────────────────────────────────────────────────
+function CancelConfirmModal({ itemCount, onConfirm, onClose }) {
+ return (
+
+
e.stopPropagation()}>
+
+
Ακύρωση παραγγελίας;
+
+ {itemCount === null
+ ? 'Ολόκληρη η παραγγελία θα ακυρωθεί οριστικά.'
+ : `${itemCount} αντικείμενο${itemCount !== 1 ? 'α' : ''} θα ακυρωθ${itemCount !== 1 ? 'ούν' : 'εί'} οριστικά.`}
+
+
+ Άκυρο
+ Ακύρωση ✕
+
+
+
+ )
+}
+
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
const payTotal = activeItems
.filter(i => payIds.includes(i.id))
@@ -504,6 +576,8 @@ export default function TableDetailPage() {
const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null)
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)
@@ -533,6 +607,12 @@ export default function TableDetailPage() {
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
useEffect(() => {
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])
}
+ 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() {
const allActive = activeItems.map(i => i.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,
padding: '10px 12px 24px',
}}>
- {/* Clear selection */}
+ {/* CLEAR */}
setSelectedIds([])}
style={{
@@ -901,13 +1003,11 @@ export default function TableDetailPage() {
borderRadius: 999,
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
border: 'none', cursor: 'pointer',
- fontSize: 13, fontWeight: 600,
+ fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
- >
- καθ. επιλ.
-
+ >CLEAR
- {/* Select all — hidden once everything is already selected */}
+ {/* ALL */}
{!allActiveSelected && (
- όλα
+ ALL
)}
- {/* Transfer items */}
+ {/* ACTIONS — opens item action modal */}
{
- setAllTables([])
- setAllOrders([])
- loadActionData()
- setActionsMode('move_items')
+ const items = activeItems.filter(i => selectedIds.includes(i.id))
+ const singleStacked = items.length === 1 && items[0].quantity > 1
+ setItemActionTarget({ items, singleStacked, multiSelect: items.length > 1 })
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px',
borderRadius: 999,
- background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
+ background: 'rgba(148,163,184,0.18)', color: '#cbd5e1',
border: 'none', cursor: 'pointer',
- fontSize: 13, fontWeight: 600,
+ fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
- μεταφορά
+ ACTIONS
{selectedIds.length}
@@ -1020,10 +1119,11 @@ export default function TableDetailPage() {
)}
- {/* Item action modal (long-press) */}
+ {/* Item action modal (long-press or ACTIONS pill) */}
{itemActionTarget && (
{
const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify(
@@ -1042,10 +1142,34 @@ export default function TableDetailPage() {
setSplitItem(itemActionTarget.items[0])
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)}
/>
)}
+ {/* Cancel confirmation */}
+ {cancelConfirm && (
+ {
+ if (cancelConfirm.mode === 'items') executeCancelItems(cancelConfirm.ids)
+ else executeCancelOrder()
+ }}
+ onClose={() => setCancelConfirm(null)}
+ />
+ )}
+
{/* Split stepper modal */}
{splitItem && (
setShowActions(false)}
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
onMerge={() => { setShowActions(false); setActionsMode('merge') }}
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
+ onCancelOrder={() => { setShowActions(false); setCancelConfirm({ mode: 'order' }) }}
/>
)}
diff --git a/waiter_pwa/src/pages/TableListPage.jsx b/waiter_pwa/src/pages/TableListPage.jsx
index f68ab33..4f75912 100644
--- a/waiter_pwa/src/pages/TableListPage.jsx
+++ b/waiter_pwa/src/pages/TableListPage.jsx
@@ -79,7 +79,7 @@ const QUICK_ACTIONS = [
{ 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 activeItems = order?.items?.filter(i => i.status === 'active') || []
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 })
{ onClose(); onAction(a.key) }} style={{
display: 'flex', alignItems: 'center', gap: 14,
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',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}>
@@ -159,6 +159,30 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
)
})}
+ {/* Cancel order */}
+ {(() => {
+ const hasActiveItems = activeItems.length > 0
+ const disabled = !order || !hasActiveItems
+ const colour = canCancel && !disabled ? '#ef4444' : 'var(--muted)'
+ return (
+ { 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',
+ }}>
+
+
+
+
+
+ Ακύρωση Παραγγελίας
+
+ )
+ })()}
@@ -358,6 +382,8 @@ export default function TableListPage() {
const [quickModal, setQuickModal] = useState(null)
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
+ const [canCancel, setCanCancel] = useState(false)
+ const [cancelConfirm, setCancelConfirm] = useState(null)
// pull-to-refresh state
const [pulling, setPulling] = useState(false)
@@ -433,6 +459,19 @@ export default function TableListPage() {
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 ───────────────────────────────────────────────────────
useEffect(() => {
if (isEmergency) return
@@ -736,12 +775,28 @@ export default function TableListPage() {
table={quickModal.table}
order={quickModal.order}
flags={quickModal.flags}
+ canCancel={canCancel}
onClose={() => setQuickModal(null)}
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
+ onCancelOrder={() => setCancelConfirm({ orderId: quickModal.order?.id })}
/>
)}
+ {cancelConfirm && (
+ setCancelConfirm(null)}>
+
e.stopPropagation()}>
+
+
Ακύρωση Παραγγελίας;
+
Όλα τα ενεργά αντικείμενα θα ακυρωθούν και θα εκτυπωθεί απόδειξη ακύρωσης.
+
+ setCancelConfirm(null)}>Ακύρωση
+ executeCancelOrder(cancelConfirm.orderId)}>Επιβεβαίωση
+
+
+
+ )}
+
{emergencyPayModal && (