From 5da378a0aedc210cf47831bac4d5f52c8e9e2cff Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 8 Jun 2026 09:58:55 +0300 Subject: [PATCH] feat: reports cost tracking, staff hourly rate, product form refactor - Backend reports: fix cost/profit calculation to handle null unit_cost with has_gap flag; expose total_cost in product & workday summary - StaffTab: add hourly_rate field in payroll section (admin-only) - ProductFormModal: major refactor of form layout and structure - ProductsTab: minor tweaks aligned with form changes - Report pages (Today, WorkDaySummary, CategoryPerformance, ProductPerformance, RevenueTrends): UI improvements and cost data Co-Authored-By: Claude Sonnet 4.6 --- local_backend/routers/reports.py | 93 +++- .../src/components/WorkdaySummaryModal.jsx | 2 +- .../src/pages/Management/ProductFormModal.jsx | 473 +++++++++--------- .../src/pages/Management/ProductsTab.jsx | 6 +- manager_dashboard/src/pages/StaffTab.jsx | 42 +- .../restaurant/CategoryPerformance.jsx | 137 +++-- .../reports/restaurant/ProductPerformance.jsx | 151 ++++-- .../reports/restaurant/RevenueTrends.jsx | 15 + .../src/pages/reports/restaurant/Today.jsx | 44 +- .../reports/restaurant/WorkDaySummary.jsx | 40 +- 10 files changed, 673 insertions(+), 330 deletions(-) diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 0738fbc..42b0209 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -948,6 +948,7 @@ def product_performance( "category_id": product.category_id if product else None, "qty_sold": 0, "revenue": 0.0, + "total_cost": 0.0, "trackable_profit": 0.0, "uncosted_revenue": 0.0, "uncosted_items": 0, @@ -959,7 +960,9 @@ def product_performance( summary[pid]["revenue"] += revenue summary[pid]["order_ids"].add(item.order_id) if item.unit_cost is not None: - summary[pid]["trackable_profit"] += revenue - (item.unit_cost * qty) + cost = item.unit_cost * qty + summary[pid]["total_cost"] += cost + summary[pid]["trackable_profit"] += revenue - cost else: summary[pid]["uncosted_revenue"] += revenue summary[pid]["uncosted_items"] += qty @@ -974,7 +977,7 @@ def product_performance( "product_id": pid, "product_name": product.name if product else f"#{pid}", "category_id": product.category_id if product else None, - "qty_sold": 0, "revenue": 0.0, "trackable_profit": 0.0, + "qty_sold": 0, "revenue": 0.0, "total_cost": 0.0, "trackable_profit": 0.0, "uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(), } @@ -984,6 +987,7 @@ def product_performance( wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0}) entry["order_count"] = len(entry.pop("order_ids")) entry["revenue"] = round(entry["revenue"], 2) + entry["total_cost"] = round(entry["total_cost"], 2) entry["trackable_profit"] = round(entry["trackable_profit"], 2) entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2) entry["has_gap"] = entry["uncosted_items"] > 0 @@ -1123,10 +1127,22 @@ def business_days_list( cancelled_orders = [o for o in orders if o.status == "cancelled"] 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 - ) + revenue = 0.0 + total_cost = 0.0 + trackable_profit = 0.0 + has_gap = False + for o in closed_orders: + for i in o.items: + if i.status not in ("active", "paid"): + continue + rev = i.unit_price * i.quantity + revenue += rev + if i.unit_cost is not None: + cost = i.unit_cost * i.quantity + total_cost += cost + trackable_profit += rev - cost + else: + has_gap = True opener = waiters_db.get(d.opened_by_id) closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None result.append({ @@ -1143,6 +1159,9 @@ def business_days_list( "waiter_count": len(waiter_ids), "shift_count": len(day_shifts), "revenue": round(revenue, 2), + "total_cost": round(total_cost, 2), + "trackable_profit": round(trackable_profit, 2), + "has_gap": has_gap, }) return {"business_days": result} @@ -1164,10 +1183,22 @@ def current_business_day( WaiterShift.business_day_id == day.id, WaiterShift.ended_at == None, ).all() - revenue = sum( - sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")) - for o in closed_orders - ) + revenue = 0.0 + total_cost = 0.0 + trackable_profit = 0.0 + has_gap = False + for o in closed_orders: + for i in o.items: + if i.status not in ("active", "paid"): + continue + rev = i.unit_price * i.quantity + revenue += rev + if i.unit_cost is not None: + cost = i.unit_cost * i.quantity + total_cost += cost + trackable_profit += rev - cost + else: + has_gap = True item_counts: dict = {} for o in orders: @@ -1189,6 +1220,9 @@ def current_business_day( "id": day.id, "opened_at": _dt(day.opened_at), "revenue": round(revenue, 2), + "total_cost": round(total_cost, 2), + "trackable_profit": round(trackable_profit, 2), + "has_gap": has_gap, "orders_closed": len(closed_orders), "orders_open": len(open_orders), "active_waiters": len(active_shifts), @@ -1229,14 +1263,22 @@ def revenue_trends( key = dt.strftime("%Y-%m-%d") if key not in buckets: - buckets[key] = {"date": key, "revenue": 0.0, "orders": 0} - rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")) - buckets[key]["revenue"] += rev + buckets[key] = {"date": key, "revenue": 0.0, "profit": 0.0, "has_gap": False, "orders": 0} + for i in order.items: + if i.status not in ("active", "paid"): + continue + rev = i.unit_price * i.quantity + buckets[key]["revenue"] += rev + if i.unit_cost is not None: + buckets[key]["profit"] += rev - i.unit_cost * i.quantity + else: + buckets[key]["has_gap"] = True buckets[key]["orders"] += 1 arr = sorted(buckets.values(), key=lambda x: x["date"]) for d in arr: d["revenue"] = round(d["revenue"], 2) + d["profit"] = round(d["profit"], 2) if granularity == "daily": for i, d in enumerate(arr): @@ -1286,18 +1328,37 @@ def category_performance( "color": cat.color if cat and hasattr(cat, "color") else None, "units_sold": 0, "revenue": 0.0, + "total_cost": 0.0, + "trackable_profit": 0.0, + "uncosted_revenue": 0.0, + "has_gap": False, "product_ids": set(), } - summary[cid]["units_sold"] += item.quantity - summary[cid]["revenue"] += item.unit_price * item.quantity + qty = item.quantity + rev = item.unit_price * qty + summary[cid]["units_sold"] += qty + summary[cid]["revenue"] += rev summary[cid]["product_ids"].add(item.product_id) + if item.unit_cost is not None: + cost = item.unit_cost * qty + summary[cid]["total_cost"] += cost + summary[cid]["trackable_profit"] += rev - cost + else: + summary[cid]["uncosted_revenue"] += rev + summary[cid]["has_gap"] = True total_rev = sum(v["revenue"] for v in summary.values()) + total_profit = sum(v["trackable_profit"] for v in summary.values()) result = [] for entry in summary.values(): entry["product_count"] = len(entry.pop("product_ids")) entry["revenue"] = round(entry["revenue"], 2) - entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1) + entry["total_cost"] = round(entry["total_cost"], 2) + entry["trackable_profit"] = round(entry["trackable_profit"], 2) + entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2) + entry["pct_rev"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1) + entry["pct_profit"] = round((entry["trackable_profit"] / total_profit * 100) if total_profit > 0 else 0, 1) + entry["pct"] = entry["pct_rev"] result.append(entry) result.sort(key=lambda x: x["revenue"], reverse=True) diff --git a/manager_dashboard/src/components/WorkdaySummaryModal.jsx b/manager_dashboard/src/components/WorkdaySummaryModal.jsx index c0f34e1..3d49a64 100644 --- a/manager_dashboard/src/components/WorkdaySummaryModal.jsx +++ b/manager_dashboard/src/components/WorkdaySummaryModal.jsx @@ -148,7 +148,7 @@ function OverviewTab({ data }) { return (
{/* Top stats */} -
+
diff --git a/manager_dashboard/src/pages/Management/ProductFormModal.jsx b/manager_dashboard/src/pages/Management/ProductFormModal.jsx index bf2369a..d2bfacb 100644 --- a/manager_dashboard/src/pages/Management/ProductFormModal.jsx +++ b/manager_dashboard/src/pages/Management/ProductFormModal.jsx @@ -192,6 +192,7 @@ function getItemTypeLabel(type) { export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) { const [form, setForm] = useState(() => buildFormFromProduct(product)) const [activeTab, setActiveTab] = useState('favorites') + const [leftTab, setLeftTab] = useState('info') const [imageFile, setImageFile] = useState(null) const [uploading, setUploading] = useState(false) const qc = useQueryClient() @@ -205,6 +206,7 @@ export default function ProductFormModal({ product, categories, printers, onSave useEffect(() => { setForm(buildFormFromProduct(product)) setActiveTab('favorites') + setLeftTab('info') setImageFile(null) }, [product.id, product.name]) @@ -488,7 +490,6 @@ export default function ProductFormModal({ product, categories, printers, onSave { key: 'options', label: 'Έξτρα', count: form.options.length }, ...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })), { key: '__add_pref__', label: '+ Προτίμηση', isAdd: true }, - { key: 'digital_menu', label: 'Digital Menu' }, ] const favList = buildFavoritesList(form) @@ -507,152 +508,264 @@ export default function ProductFormModal({ product, categories, printers, onSave {/* Body */}
- {/* LEFT: product info */} -
-

Στοιχεία προϊόντος

- -
- - setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" /> + {/* LEFT: tabbed product info — 30% width */} +
+ {/* Left tab bar */} +
+ {[ + { key: 'info', label: 'Πληροφορίες' }, + { key: 'digital', label: 'Digital Menu' }, + { key: 'cost', label: 'Κόστος' }, + ].map(t => ( + + ))}
- {/* Description — optional, for digital menus / staff info */} -
- -