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 στον εκτυπωτή; +

+
+ + +
+
+
+ )} ) } 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
+
+ Δικαιώματα + +
Κωδικός PIN

Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε. @@ -788,6 +805,24 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU +

+ Δικαιώματα + +
+

Κωδικός 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)} + +

+ } + > + {/* Metric checkboxes */} +
+ {METRICS.map(m => { + const active = activeMetrics.has(m.key) + return ( + + ) + })} +
+ +
- + - - - } 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) => ( - )} + ))} + +
+
+
+ ) +} + 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 */} + >CLEAR - {/* Select all — hidden once everything is already selected */} + {/* ALL */} {!allActiveSelected && (
)} - {/* 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 }) ) })} + {/* Cancel order */} + {(() => { + const hasActiveItems = activeItems.length > 0 + const disabled = !order || !hasActiveItems + const colour = canCancel && !disabled ? '#ef4444' : 'var(--muted)' + return ( + + ) + })()}
@@ -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()}> +
+

Ακύρωση Παραγγελίας;

+

Όλα τα ενεργά αντικείμενα θα ακυρωθούν και θα εκτυπωθεί απόδειξη ακύρωσης.

+
+ + +
+
+
+ )} + {emergencyPayModal && (