From 5de89a722c77f902dcc318167a8d557474f0352e Mon Sep 17 00:00:00 2001 From: bonamin Date: Thu, 21 May 2026 15:24:54 +0300 Subject: [PATCH] feat: major dashboard & waiter PWA overhaul - Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 1 + local_backend/routers/business_day.py | 27 + local_backend/routers/orders.py | 113 ++- local_backend/routers/products.py | 17 + local_backend/routers/reports.py | 121 ++- local_backend/routers/shifts.py | 159 +++- local_backend/services/cloud_sync.py | 7 +- manager_dashboard/src/App.jsx | 8 +- manager_dashboard/src/components/Sidebar.jsx | 4 +- .../{OperationsPage.jsx => DashboardPage.jsx} | 209 ++--- manager_dashboard/src/pages/DashboardTab.jsx | 788 ------------------ .../src/pages/OrderDetailPage.jsx | 148 +++- .../src/pages/Settings/SettingsPage.jsx | 3 - .../src/pages/reports/ReportsPage.jsx | 14 +- .../pages/reports/restaurant/OrderHistory.jsx | 52 +- .../reports/restaurant/WorkDaySummary.jsx | 257 +++++- .../pages/reports/shared/OrderDetailModal.jsx | 286 +++++++ .../pages/reports/shared/ShiftDetailModal.jsx | 263 ++++++ .../pages/reports/staff/ShiftsOverview.jsx | 99 ++- manager_dashboard/src/ui/Badge.jsx | 1 + .../src/ui/DeleteConfirmModal.jsx | 101 +++ .../src/ui/PaymentMethodModal.jsx | 54 ++ manager_dashboard/src/ui/tokens.js | 1 + waiter_pwa/package-lock.json | 27 + waiter_pwa/package.json | 1 + waiter_pwa/public/icons/Icon_Bird_512x512.png | Bin 28248 -> 0 bytes waiter_pwa/public/icons/icon-192.png | Bin 28248 -> 48227 bytes waiter_pwa/public/icons/icon-512.png | Bin 28248 -> 296068 bytes waiter_pwa/public/icons/icons8-favicon-48.png | Bin 1572 -> 0 bytes waiter_pwa/src/App.jsx | 15 +- .../src/components/ConnectionLostModal.jsx | 75 +- waiter_pwa/src/components/TableCard.jsx | 28 +- waiter_pwa/src/context/SSEContext.jsx | 35 +- waiter_pwa/src/db/posdb.js | 14 +- waiter_pwa/src/hooks/useProductCache.js | 69 ++ waiter_pwa/src/index.css | 9 +- waiter_pwa/src/pages/AddItemsPage.jsx | 14 +- waiter_pwa/src/pages/TableListPage.jsx | 5 +- waiter_pwa/src/store/connectionStore.js | 48 +- waiter_pwa/vite.config.js | 4 +- 40 files changed, 1906 insertions(+), 1171 deletions(-) rename manager_dashboard/src/pages/{OperationsPage.jsx => DashboardPage.jsx} (90%) delete mode 100644 manager_dashboard/src/pages/DashboardTab.jsx create mode 100644 manager_dashboard/src/pages/reports/shared/OrderDetailModal.jsx create mode 100644 manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx create mode 100644 manager_dashboard/src/ui/DeleteConfirmModal.jsx create mode 100644 manager_dashboard/src/ui/PaymentMethodModal.jsx delete mode 100644 waiter_pwa/public/icons/Icon_Bird_512x512.png delete mode 100644 waiter_pwa/public/icons/icons8-favicon-48.png create mode 100644 waiter_pwa/src/hooks/useProductCache.js diff --git a/docker-compose.yml b/docker-compose.yml index 2e4ffcc..00d098c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24} - DATABASE_URL=sqlite:////app/data/pos.db - VERSION=${VERSION:-0.0.0} + - HOST_IP=${HOST_IP:-} volumes: - ${DATA_PATH}:/app/data - ${LOGO_PATH}:/app/logo.png:ro diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py index e9846c8..ffa2a87 100644 --- a/local_backend/routers/business_day.py +++ b/local_backend/routers/business_day.py @@ -162,6 +162,33 @@ def close_business_day( return day +@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_business_day( + day_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Permanently delete a business day. Only allowed when it has no shifts.""" + day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first() + if not day: + raise HTTPException(status_code=404, detail="Business day not found") + if day.status == "open": + raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first") + + shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count() + if shift_count > 0: + raise HTTPException( + status_code=409, + detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.", + ) + + db.query(Order).filter(Order.business_day_id == day_id).update( + {"business_day_id": None}, synchronize_session=False + ) + db.delete(day) + db.commit() + + @router.get("/history") def business_day_history( db: Session = Depends(get_db), diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index d157f3f..36a931e 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -120,14 +120,83 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c ] -@router.get("/{order_id}", response_model=OrderOut) +@router.get("/{order_id}") def get_order(order_id: int, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") - return order + + # Resolve all user IDs referenced by this order in one query + user_ids = set() + user_ids.add(order.opened_by) + if order.closed_by: + user_ids.add(order.closed_by) + for item in order.items: + user_ids.add(item.added_by) + if item.paid_by: + user_ids.add(item.paid_by) + for log in order.audit_logs: + if log.waiter_id: + user_ids.add(log.waiter_id) + + users = db.query(User).filter(User.id.in_(user_ids)).all() + name_map = {u.id: u.nickname or u.full_name or u.username for u in users} + + def fmt_item(i): + return { + "id": i.id, + "order_id": i.order_id, + "product_id": i.product_id, + "product": {"id": i.product.id, "name": i.product.name} if i.product else None, + "added_by": i.added_by, + "added_by_name": name_map.get(i.added_by), + "quantity": i.quantity, + "unit_price": i.unit_price, + "selected_options": i.selected_options, + "removed_ingredients": i.removed_ingredients, + "notes": i.notes, + "status": i.status, + "added_at": i.added_at, + "printed": i.printed, + "paid_by": i.paid_by, + "paid_by_name": name_map.get(i.paid_by) if i.paid_by else None, + "paid_at": i.paid_at, + "payment_method": i.payment_method, + "paid_in_shift_id": i.paid_in_shift_id, + } + + def fmt_log(l): + return { + "id": l.id, + "order_id": l.order_id, + "event_type": l.event_type, + "waiter_id": l.waiter_id, + "waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None, + "item_ids": l.item_ids, + "amount": l.amount, + "payment_method": l.payment_method, + "note": l.note, + "created_at": l.created_at, + "offline_at": l.offline_at, + "is_duplicate": l.is_duplicate, + } + + return { + "id": order.id, + "table_id": order.table_id, + "opened_by": order.opened_by, + "opened_at": order.opened_at, + "status": order.status, + "closed_at": order.closed_at, + "closed_by": order.closed_by, + "notes": order.notes, + "business_day_id": order.business_day_id, + "items": [fmt_item(i) for i in order.items], + "waiters": [{"waiter_id": w.waiter_id} for w in order.waiters], + "audit_logs": [fmt_log(l) for l in order.audit_logs], + } @router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED) @@ -310,10 +379,26 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("paid", "open", "partially_paid"): raise HTTPException(status_code=400, detail="Cannot close order in current status") + + now = datetime.now(timezone.utc) + + # Mark all still-active items as 'closed' — unpaid, closed by manager + active_items = db.query(OrderItem).filter( + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + closed_item_ids = [] + for item in active_items: + item.status = "closed" + closed_item_ids.append(item.id) + order.status = "closed" - order.closed_at = datetime.now(timezone.utc) + order.closed_at = now order.closed_by = user.id - _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id) + + note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None + _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id, + item_ids=closed_item_ids if closed_item_ids else None, note=note) db.commit() broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) return {"status": "closed"} @@ -419,10 +504,26 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") + + now = datetime.now(timezone.utc) + + # Cancel all still-active items + active_items = db.query(OrderItem).filter( + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + cancelled_item_ids = [] + for item in active_items: + item.status = "cancelled" + cancelled_item_ids.append(item.id) + order.status = "cancelled" - order.closed_at = datetime.now(timezone.utc) + order.closed_at = now order.closed_by = user.id - _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id) + + note = f"Ακύρωση από manager — {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}) diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index 7db4c88..5b82b78 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -17,9 +17,14 @@ from schemas.product import ( CategoryReparentRequest, ) from routers.deps import get_current_user, require_manager +from services.sse_bus import broadcast_sync router = APIRouter() + +def _broadcast_products_changed(): + broadcast_sync("products_changed", {}) + IMAGE_DIR = "/app/data/product_images" @@ -118,6 +123,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U db.add(cat) db.commit() db.refresh(cat) + _broadcast_products_changed() return cat @@ -128,6 +134,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g if cat: cat.sort_order = item.sort_order db.commit() + _broadcast_products_changed() @router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT) @@ -138,6 +145,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep if cat: cat.sort_order = item.sort_order db.commit() + _broadcast_products_changed() @router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT) @@ -148,6 +156,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends if cat: cat.general_sort_order = item.general_sort_order db.commit() + _broadcast_products_changed() @router.put("/categories/{category_id}/reparent", response_model=CategoryOut) @@ -176,6 +185,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi cat.sort_order = sibling_count db.commit() db.refresh(cat) + _broadcast_products_changed() return cat @@ -188,6 +198,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend setattr(cat, field, value) db.commit() db.refresh(cat) + _broadcast_products_changed() return cat @@ -198,6 +209,7 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User raise HTTPException(status_code=404, detail="Category not found") db.delete(cat) db.commit() + _broadcast_products_changed() # ── Products ────────────────────────────────────────────────────────────────── @@ -218,6 +230,7 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_ if product: product.sort_order = item.sort_order db.commit() + _broadcast_products_changed() @router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) @@ -255,6 +268,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use _replace_preference_sets(db, product, body.preference_sets) db.commit() db.refresh(product) + _broadcast_products_changed() return product @@ -275,6 +289,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g _replace_preference_sets(db, product, body.preference_sets) db.commit() db.refresh(product) + _broadcast_products_changed() return product @@ -305,6 +320,7 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db product.image_url = f"/static/product_images/{filename}" db.commit() db.refresh(product) + _broadcast_products_changed() return product @@ -329,3 +345,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge else: db.delete(product) db.commit() + _broadcast_products_changed() diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index c94bce5..91bcaf1 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -178,7 +178,7 @@ def shift_orders_summary( return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} -@router.get("/orders/history", response_model=List[OrderOut]) +@router.get("/orders/history") def order_history( from_date: Optional[str] = Query(default=None, alias="from"), to_date: Optional[str] = Query(default=None, alias="to"), @@ -191,7 +191,14 @@ def order_history( db: Session = Depends(get_db), user: User = Depends(require_manager), ): - q = db.query(Order) + from sqlalchemy.orm import joinedload + from models.table import Table as TableModel + + q = db.query(Order).options( + joinedload(Order.items).joinedload(OrderItem.product), + joinedload(Order.waiters), + joinedload(Order.audit_logs), + ) if business_day_id: q = q.filter(Order.business_day_id == business_day_id) elif from_date or to_date: @@ -205,7 +212,109 @@ def order_history( q = q.filter(Order.status == order_status) if table_id: q = q.filter(Order.table_id == table_id) - return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + # Collect all waiter IDs and table IDs to resolve in bulk + waiter_ids: set[int] = set() + table_ids: set[int] = set() + item_waiter_ids: set[int] = set() + for o in orders: + if o.opened_by: + waiter_ids.add(o.opened_by) + if o.closed_by: + waiter_ids.add(o.closed_by) + if o.table_id: + table_ids.add(o.table_id) + for item in o.items: + if item.added_by: + item_waiter_ids.add(item.added_by) + if item.paid_by: + item_waiter_ids.add(item.paid_by) + for log in o.audit_logs: + if log.waiter_id: + waiter_ids.add(log.waiter_id) + + all_waiter_ids = waiter_ids | item_waiter_ids + waiters_map: dict[int, str] = {} + if all_waiter_ids: + for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all(): + waiters_map[w.id] = w.full_name or w.username + + tables_map: dict[int, str] = {} + if table_ids: + for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all(): + prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else "" + tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}" + + def _wname(wid): + if wid is None: + return None + return waiters_map.get(wid, f"#{wid}") + + def _dt_local(dt): + if dt is None: + return None + return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() + + result = [] + for o in orders: + items_out = [] + for item in o.items: + items_out.append({ + "id": item.id, + "order_id": item.order_id, + "product_id": item.product_id, + "product": {"id": item.product.id, "name": item.product.name} if item.product else None, + "added_by": item.added_by, + "added_by_name": _wname(item.added_by), + "added_at": _dt_local(item.added_at), + "quantity": item.quantity, + "unit_price": float(item.unit_price), + "status": item.status, + "paid_by": item.paid_by, + "paid_by_name": _wname(item.paid_by), + "paid_at": _dt_local(item.paid_at), + "payment_method": item.payment_method, + "paid_in_shift_id": item.paid_in_shift_id, + "notes": item.notes, + "printed": item.printed, + "selected_options": item.selected_options, + "removed_ingredients": item.removed_ingredients, + }) + audit_out = [] + for log in o.audit_logs: + audit_out.append({ + "id": log.id, + "order_id": log.order_id, + "event_type": log.event_type, + "waiter_id": log.waiter_id, + "waiter_name": _wname(log.waiter_id), + "item_ids": log.item_ids, + "amount": log.amount, + "payment_method": log.payment_method, + "note": log.note, + "created_at": _dt_local(log.created_at), + "offline_at": log.offline_at, + "is_duplicate": log.is_duplicate, + }) + result.append({ + "id": o.id, + "table_id": o.table_id, + "table_name": tables_map.get(o.table_id) if o.table_id else None, + "opened_by": o.opened_by, + "opened_by_name": _wname(o.opened_by), + "opened_at": _dt_local(o.opened_at), + "closed_by": o.closed_by, + "closed_by_name": _wname(o.closed_by), + "closed_at": _dt_local(o.closed_at), + "status": o.status, + "notes": o.notes, + "business_day_id": o.business_day_id, + "items": items_out, + "waiters": [{"waiter_id": w.waiter_id} for w in o.waiters], + "audit_logs": audit_out, + }) + return result @router.get("/tables/summary") @@ -689,9 +798,8 @@ def business_days_list( orders = db.query(Order).filter(Order.business_day_id == d.id).all() closed_orders = [o for o in orders if o.status in ("closed", "paid")] cancelled_orders = [o for o in orders if o.status == "cancelled"] - waiter_ids = set() - for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()): - waiter_ids.add(s.waiter_id) + day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all() + waiter_ids = {s.waiter_id for s in day_shifts} revenue = sum( sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")) for o in closed_orders @@ -710,6 +818,7 @@ def business_days_list( "closed_order_count": len(closed_orders), "cancellation_count": len(cancelled_orders), "waiter_count": len(waiter_ids), + "shift_count": len(day_shifts), "revenue": round(revenue, 2), }) return {"business_days": result} diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py index be89b6c..57a375d 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -289,22 +289,66 @@ def get_shift( return _enrich_shift(shift, db) +@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_shift( + shift_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Permanently delete a shift, its breaks, and all orders opened during that shift.""" + from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter + import json as _json + + shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail="Shift not found") + if shift.ended_at is None: + raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first") + + # Find all orders opened during this shift's time window by this waiter + orders = db.query(Order).filter( + Order.opened_by == shift.waiter_id, + Order.opened_at >= shift.started_at, + Order.opened_at <= shift.ended_at, + ).all() + + for order in orders: + db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False) + db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False) + db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False) + db.delete(order) + + db.delete(shift) + db.commit() + + @router.get("/{shift_id}/summary") def get_shift_summary( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): - """Full shift summary: enriched shift data + paid items grouped by order.""" + """Full shift summary with complete per-item waiter attribution. + + Returns all items that either: + - were paid in this shift (paid_in_shift_id == shift_id), OR + - were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift. + + Each item includes added_by_name, paid_by_name, timestamps, and order context. + Orders include opener_name and closer_name. + """ from models.order import Order + from models.table import Table from sqlalchemy.orm import joinedload shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") - from models.table import Table - items = db.query(OrderItem).options( + waiter_id = shift.waiter_id + + # Collect all relevant items: paid in this shift OR added by this waiter + paid_items = db.query(OrderItem).options( joinedload(OrderItem.product), joinedload(OrderItem.order), ).filter( @@ -312,48 +356,115 @@ def get_shift_summary( OrderItem.status == "paid", ).all() - # Build table_id -> display name map for all referenced tables - table_ids = {item.order.table_id for item in items if item.order and item.order.table_id} + ordered_items = db.query(OrderItem).options( + joinedload(OrderItem.product), + joinedload(OrderItem.order), + ).filter( + OrderItem.added_by == waiter_id, + OrderItem.added_at >= shift.started_at, + OrderItem.added_at <= shift.ended_at, + (OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None), + ).all() + + # Deduplicate: union by item id, paid_items takes precedence + items_by_id: dict[int, OrderItem] = {} + for item in ordered_items: + items_by_id[item.id] = item + for item in paid_items: + items_by_id[item.id] = item + all_items = list(items_by_id.values()) + + # Build lookup maps + all_waiter_ids = set() + for item in all_items: + all_waiter_ids.add(item.added_by) + if item.paid_by: + all_waiter_ids.add(item.paid_by) + all_order_ids = {item.order_id for item in all_items} + + # Load orders with opener/closer + orders_db: dict[int, Order] = {} + if all_order_ids: + for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all(): + orders_db[o.id] = o + if o.opened_by: + all_waiter_ids.add(o.opened_by) + if o.closed_by: + all_waiter_ids.add(o.closed_by) + + # Load table names + table_ids = {o.table_id for o in orders_db.values() if o.table_id} tables_map: dict[int, str] = {} if table_ids: - tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all() - for t in tbl_rows: + for t in db.query(Table).filter(Table.id.in_(table_ids)).all(): prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else "" tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}" - orders_seen = {} - for item in items: + # Load waiter names + waiters_map: dict[int, str] = {} + if all_waiter_ids: + for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all(): + waiters_map[w.id] = w.full_name or w.username + + def _wname(wid): + if wid is None: + return None + return waiters_map.get(wid, f"#{wid}") + + # Build orders dict + orders_out: dict[int, dict] = {} + for oid, o in orders_db.items(): + tid = o.table_id + orders_out[oid] = { + "order_id": oid, + "table_id": tid, + "table_name": tables_map.get(tid) if tid else None, + "opened_at": _dt(o.opened_at), + "closed_at": _dt(o.closed_at), + "opener_name": _wname(o.opened_by), + "closer_name": _wname(o.closed_by), + "status": o.status, + "items": [], + } + + # Attach items to their orders + for item in all_items: oid = item.order_id - if oid not in orders_seen: - o = item.order - tid = o.table_id if o else None - orders_seen[oid] = { - "order_id": oid, - "table_id": tid, - "table_name": tables_map.get(tid) if tid else None, - "opened_at": _dt(o.opened_at) if o else None, - "items": [], - } - orders_seen[oid]["items"].append({ + if oid not in orders_out: + continue + orders_out[oid]["items"].append({ "id": item.id, "product_name": item.product.name if item.product else f"#{item.product_id}", "quantity": item.quantity, "unit_price": float(item.unit_price), "subtotal": round(float(item.unit_price) * item.quantity, 2), + "status": item.status, + "added_by_id": item.added_by, + "added_by_name": _wname(item.added_by), + "added_at": _dt(item.added_at), + "paid_by_id": item.paid_by, + "paid_by_name": _wname(item.paid_by), "paid_at": _dt(item.paid_at), + "paid_in_shift_id": item.paid_in_shift_id, + "payment_method": item.payment_method, }) - # Compute hours worked + # Remove orders with no items (shouldn't happen but safety net) + populated_orders = [o for o in orders_out.values() if o["items"]] + + # Compute duration started = shift.started_at ended = shift.ended_at duration_minutes = None if started and ended: duration_minutes = int((ended - started).total_seconds() / 60) elif started: - from datetime import datetime, timezone as tz - duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60) + from datetime import timezone as tz + now = datetime.now(tz.utc) + s = started if started.tzinfo else started.replace(tzinfo=tz.utc) + duration_minutes = int((now - s).total_seconds() / 60) enriched = _enrich_shift(shift, db) - enriched["orders"] = list(orders_seen.values()) + enriched["orders"] = populated_orders enriched["duration_minutes"] = duration_minutes return enriched diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py index 0590c1b..8503d8c 100644 --- a/local_backend/services/cloud_sync.py +++ b/local_backend/services/cloud_sync.py @@ -76,7 +76,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict: def _get_local_ip() -> str | None: - """Best-effort detection of the machine's LAN IP address.""" + """Best-effort detection of the host machine's LAN IP address. + When running inside Docker the socket trick returns the container/bridge IP, + so we honour HOST_IP if it is explicitly provided via the environment.""" + import os + if host_ip := os.environ.get("HOST_IP", "").strip(): + return host_ip try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)) diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index 0d52837..3474d29 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -4,7 +4,7 @@ import useAuthStore from './store/authStore' import AppLayout from './layouts/AppLayout' import LoginPage from './pages/LoginPage' import SetupWizard from './pages/SetupWizard' -import OperationsPage from './pages/OperationsPage' +import DashboardPage from './pages/DashboardPage' import TablesPage from './pages/TablesPage' import OrderDetailPage from './pages/OrderDetailPage' import ManagementPage from './pages/ManagementPage' @@ -74,9 +74,9 @@ export default function App() { } /> } /> }> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index 5adb490..e47ad5b 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react' const NAV = [ - { to: '/operations', icon: BarChart2, label: 'Διοίκηση' }, + { to: '/dashboard', icon: BarChart2, label: 'Dashboard' }, { to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' }, { to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, { to: '/management', icon: Package, label: 'Διαχείριση' }, @@ -17,7 +17,7 @@ export default function Sidebar() {