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 => (
+ setLeftTab(t.key)}
+ className={`flex-1 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
+ leftTab === t.key
+ ? 'border-primary-600 text-primary-700 bg-primary-50/50'
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
+ }`}>
+ {t.label}
+
+ ))}
- {/* Description — optional, for digital menus / staff info */}
-
-
Περιγραφή (προαιρετική)
-
+ {/* Left tab content */}
+
-
-
Τιμή βάσης (€) *
-
setField('base_price', v)} className="w-full" />
-
-
- {/* Phase 2A — Cost section */}
-
-
-
Κόστος
-
- {['simple', 'detailed'].map(mode => (
-
setField('cost_mode', mode)}
- className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
- form.cost_mode === mode
- ? 'bg-primary-600 text-white'
- : 'text-gray-500 hover:bg-gray-100'
- }`}>
- {mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
-
- ))}
+ {/* Tab 1: Main Info */}
+ {leftTab === 'info' && (<>
+
+ Όνομα *
+ setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
-
-
- {form.cost_mode === 'simple' ? (
-
-
Κόστος ανά μονάδα (€)
-
setField('cost_simple', v)} className="w-full" />
- {form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
- const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
- const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
- return (
-
- {isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
-
- )
- })()}
-
- ) : (
-
- {form.cost_breakdown.map((entry, bi) => (
-
- ))}
-
setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
- className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους
- {form.cost_breakdown.length > 0 && (() => {
- const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
- const price = parseFloat(form.base_price) || 0
- const isError = total > price && price > 0
- const margin = price > 0 ? ((price - total) / price * 100) : null
- return (
-
- Σύνολο: €{total.toFixed(2)}
- {margin !== null && !isError &&
({margin.toFixed(1)}% περιθώριο) }
- {isError &&
— Κόστος > Τιμή! }
+
+
+
Περιγραφή (προαιρετική)
+
+
+
+
Βασική Τιμή (€) *
+
setField('base_price', v)} className="w-full" />
+
+
+
+ Κατηγορία
+ setField('category_id', e.target.value)}>
+ — Χωρίς κατηγορία —
+ {categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
+ const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
+ return [
+ {parent.name} ,
+ ...subs.map(s => ↳ {s.name} ),
+ ]
+ })}
+
+
+
+
+ Ζώνη εκτυπωτή
+ setField('printer_zone_id', e.target.value)}>
+ — Χωρίς εκτυπωτή —
+ {printers.map(p => {p.name} )}
+
+
+
+
setField('is_available', !form.is_available)}
+ className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
+ form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
+ : 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
+ }`}>
+
+ {form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
+
+
+
+
Εικόνα προϊόντος
+ {product.image_url && (
+
+ )}
+ {imageFile &&
{imageFile.name}
}
+
+
+ {imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
+ setImageFile(e.target.files[0] ?? null)} />
+
+
+ >)}
+
+ {/* Tab 2: Digital Menu */}
+ {leftTab === 'digital' && (() => {
+ const basePrice = parseFloat(form.base_price) || 0
+ const dp = form.digital_price !== '' ? parseFloat(form.digital_price) : null
+ const disc = form.digital_discount !== '' ? parseFloat(form.digital_discount) : null
+ let customerPrice = basePrice
+ if (dp !== null && !isNaN(dp)) customerPrice = dp
+ else if (disc !== null && !isNaN(disc) && disc > 0) customerPrice = Math.round(basePrice * (1 - disc / 100) * 100) / 100
+
+ return (
+
+
+
Ορατότητα
+
+
+ setField('digital_visible', e.target.checked)}
+ className="w-4 h-4 accent-primary-700" />
+ Show on digital menu
+
+
+
+ setField('digital_available', e.target.checked)}
+ className="w-4 h-4 accent-primary-700" />
+ Available for ordering
+
+
Uncheck to show as ‘Out of stock’
- )
- })()}
+
+
+
+
+
Display overrides
+
Leave blank to use the standard product value
+
+
+ Display name (override)
+ setField('digital_name', e.target.value)}
+ placeholder={form.name || 'Leave blank to use product name'} />
+
+
+ Description
+
+
+ Image URL
+ setField('digital_image_url', e.target.value)}
+ placeholder="https://…" />
+
+
+
+
+
+
Pricing
+
+
+ Online price (override)
+ setField('digital_price', e.target.value)}
+ placeholder="e.g. 9.50" />
+
+
+ Discount %
+ setField('digital_discount', e.target.value)}
+ placeholder="e.g. 10 for 10% off" />
+
+
+ Customer sees: €{customerPrice.toFixed(2)}
+
+
+
- )}
-
-
+ )
+ })()}
-
- Κατηγορία
- setField('category_id', e.target.value)}>
- — Χωρίς κατηγορία —
- {categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
- const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
- return [
- {parent.name} ,
- ...subs.map(s => ↳ {s.name} ),
- ]
- })}
-
-
-
-
- Ζώνη εκτυπωτή
- setField('printer_zone_id', e.target.value)}>
- — Χωρίς εκτυπωτή —
- {printers.map(p => {p.name} )}
-
-
-
-
setField('is_available', !form.is_available)}
- className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
- form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
- : 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
- }`}>
-
- {form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
-
-
- {/* Image upload */}
-
-
Εικόνα προϊόντος
- {product.image_url && (
-
+ {/* Tab 3: Cost */}
+ {leftTab === 'cost' && (
+
+
Το κόστος καταγράφεται στιγμιότυπα σε κάθε παραγγελία για υπολογισμό κέρδους.
+
+
+
Τύπος κόστους
+
+ {['simple', 'detailed'].map(mode => (
+ setField('cost_mode', mode)}
+ className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
+ form.cost_mode === mode
+ ? 'bg-primary-600 text-white'
+ : 'text-gray-500 hover:bg-gray-100'
+ }`}>
+ {mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
+
+ ))}
+
+
+
+ {form.cost_mode === 'simple' ? (
+
+
Κόστος ανά μονάδα (€)
+
setField('cost_simple', v)} className="w-full" />
+ {form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
+ const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
+ const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
+ return (
+
+ {isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
+
+ )
+ })()}
+
+ ) : (
+
+ {form.cost_breakdown.map((entry, bi) => (
+
+ ))}
+
setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
+ className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους
+ {form.cost_breakdown.length > 0 && (() => {
+ const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
+ const price = parseFloat(form.base_price) || 0
+ const isError = total > price && price > 0
+ const margin = price > 0 ? ((price - total) / price * 100) : null
+ return (
+
+ Σύνολο: €{total.toFixed(2)}
+ {margin !== null && !isError && ({margin.toFixed(1)}% περιθώριο) }
+ {isError && — Κόστος > Τιμή! }
+
+ )
+ })()}
+
+ )}
+
+
+
)}
- {imageFile &&
{imageFile.name}
}
-
-
- {imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
- setImageFile(e.target.files[0] ?? null)} />
-
+
- {/* RIGHT: tabs */}
-
+ {/* RIGHT: ingredient/option tabs — 70% */}
+
{tabs.map(tab => {
if (tab.isAdd) return (
@@ -794,94 +907,6 @@ export default function ProductFormModal({ product, categories, printers, onSave
)}
- {/* Digital Menu tab */}
- {activeTab === 'digital_menu' && (() => {
- const basePrice = parseFloat(form.base_price) || 0
- const dp = form.digital_price !== '' ? parseFloat(form.digital_price) : null
- const disc = form.digital_discount !== '' ? parseFloat(form.digital_discount) : null
- let customerPrice = basePrice
- if (dp !== null && !isNaN(dp)) customerPrice = dp
- else if (disc !== null && !isNaN(disc) && disc > 0) customerPrice = Math.round(basePrice * (1 - disc / 100) * 100) / 100
-
- return (
-
- {/* Section 1 — Visibility */}
-
-
Ορατότητα
-
-
- setField('digital_visible', e.target.checked)}
- className="w-4 h-4 accent-primary-700" />
- Show on digital menu
-
-
-
- setField('digital_available', e.target.checked)}
- className="w-4 h-4 accent-primary-700" />
- Available for ordering
-
-
Uncheck to show as ‘Out of stock’
-
-
-
-
- {/* Section 2 — Display overrides */}
-
-
Display overrides
-
Leave blank to use the standard product value
-
-
- Display name (override)
- setField('digital_name', e.target.value)}
- placeholder={form.name || 'Leave blank to use product name'} />
-
-
- Description
-
-
- Image URL
- setField('digital_image_url', e.target.value)}
- placeholder="https://…" />
-
-
-
-
- {/* Section 3 — Pricing */}
-
-
Pricing
-
-
- Online price (override)
- setField('digital_price', e.target.value)}
- placeholder="e.g. 9.50" />
-
-
- Discount %
- setField('digital_discount', e.target.value)}
- placeholder="e.g. 10 for 10% off" />
-
-
- Customer sees: €{customerPrice.toFixed(2)}
-
-
-
-
- )
- })()}
-
{/* Preference set tab */}
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
const si = activeTab
diff --git a/manager_dashboard/src/pages/Management/ProductsTab.jsx b/manager_dashboard/src/pages/Management/ProductsTab.jsx
index 7814fea..9462e27 100644
--- a/manager_dashboard/src/pages/Management/ProductsTab.jsx
+++ b/manager_dashboard/src/pages/Management/ProductsTab.jsx
@@ -903,10 +903,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
Επεξεργασία
- {/* Delete — pill style */}
+ {/* Delete — icon only, same height as Edit pill */}
{ e.stopPropagation(); onArchive(p) }}
style={{
- display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 10px',
+ height: 27,
background: '#fff5f5', border: '1px solid #fecaca',
borderRadius: 999, fontSize: 11.5, fontWeight: 500, color: '#ef4444',
cursor: 'pointer',
@@ -914,7 +915,6 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
className="hover:bg-red-100"
title="Διαγραφή">
- Διαγραφή
diff --git a/manager_dashboard/src/pages/StaffTab.jsx b/manager_dashboard/src/pages/StaffTab.jsx
index e9026c4..dd1630d 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: '' }
+const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '' }
// ── Main page ─────────────────────────────────────────────────────────────────
export default function WaitersPage() {
@@ -424,6 +424,7 @@ export default function WaitersPage() {
nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null,
email: newForm.email || null, note: newForm.note || null,
pin: newForm.pin, role: newForm.role, is_active: true,
+ hourly_rate: newForm.hourly_rate !== '' ? parseFloat(newForm.hourly_rate) : null,
})}
/>
)}
@@ -584,8 +585,30 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
- {/* Column 3: PIN */}
-
+ {/* Column 3: Payroll + PIN */}
+
+
+
Μισθοδοσία
+
+
+ f('hourly_rate', e.target.value)}
+ />
+ {form.hourly_rate && parseFloat(form.hourly_rate) > 0 && (
+
+ €{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
+
+ )}
+ {!form.hourly_rate && (
+ Δεν παρακολουθείται
+ )}
+
+
+
Κωδικός PIN
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
@@ -725,11 +748,14 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
Πληροφορίες
-
-
+ {/* Row 1: Date + Status side by side */}
+
+
+
+
{waiter.role === 'waiter' && (
diff --git a/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx b/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
index f2ab53e..0e44dcd 100644
--- a/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/CategoryPerformance.jsx
@@ -286,6 +286,7 @@ export default function CategoryPerformance() {
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
+ const [pieMetric, setPieMetric] = useState('revenue')
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
@@ -313,7 +314,15 @@ export default function CategoryPerformance() {
const allProducts = allProductsData?.products || []
const soldProducts = productsData?.products || []
const categories = data?.categories || []
- const pieData = categories.map((c, i) => ({ name: c.category_name, value: c.revenue, color: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }))
+ const anyHasCost = categories.some(c => (c.total_cost || 0) > 0)
+ const pieCategories = pieMetric === 'profit'
+ ? categories.filter(c => (c.trackable_profit || 0) > 0)
+ : categories
+ const pieData = pieCategories.map((c, i) => ({
+ name: c.category_name,
+ value: pieMetric === 'profit' ? c.trackable_profit : c.revenue,
+ color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
+ }))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
@@ -352,49 +361,109 @@ export default function CategoryPerformance() {
<>
-
-
-
-
-
- {pieData.map((d, i) => | )}
-
- fmtEUR(v)} />} />
-
-
-
+
+ {[['revenue', 'Έσοδα'], ['profit', 'Κέρδος']].map(([m, label]) => (
+ setPieMetric(m)} className={`rounded px-2 py-1 font-medium transition ${pieMetric === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}
+ ))}
+
+ ) : null
+ }>
+ {pieData.length === 0 ? (
+
Δεν υπάρχουν δεδομένα κέρδους
+ ) : (
+
+
+
+
+ {pieData.map((d, i) => | )}
+
+ fmtEUR(v)} />} />
+
+
+
+ )}
- {categories.map((c, i) => (
-
-
-
-
{c.category_name}
+ {categories.map((c, i) => {
+ const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
+ ? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
+ : null
+ return (
+
+
+
+ {c.category_name}
+
+
{fmtEUR(c.revenue)}
+
+ {c.units_sold} τεμ. · {c.product_count} προϊόντα
+ {c.pct_rev ?? c.pct}%
+
+ {(c.trackable_profit || 0) > 0 && (
+
+ {fmtEUR(c.trackable_profit)} κέρδος
+ {margin && {margin}{c.has_gap && ' ⚠'} }
+
+ )}
-
{fmtEUR(c.revenue)}
-
- {c.units_sold} τεμ. · {c.product_count} προϊόντα
- {c.pct}%
-
-
- ))}
+ )
+ })}
- Κατηγορία Προϊόντα Τεμάχια Συνολικά Έσοδα % Συνόλου
+
+ Κατηγορία
+ Προϊόντα
+ Τεμάχια
+ Έσοδα
+ {anyHasCost && Κόστος }
+ {anyHasCost && Κέρδος }
+ % Εσόδων
+ {anyHasCost && % Κέρδους }
+
- {categories.map((c, i) => (
-
- {c.category_name}
- {c.product_count}
- {fmtNum(c.units_sold)}
- {fmtEUR(c.revenue)}
- {c.pct}%
-
- ))}
+ {categories.map((c, i) => {
+ const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
+ ? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
+ : null
+ return (
+
+ {c.category_name}
+ {c.product_count}
+ {fmtNum(c.units_sold)}
+ {fmtEUR(c.revenue)}
+ {anyHasCost && (
+
+ {(c.total_cost || 0) > 0 ? fmtEUR(c.total_cost) : — }
+
+ )}
+ {anyHasCost && (
+
+ {(c.trackable_profit || 0) > 0 ? (
+
+ {fmtEUR(c.trackable_profit)}
+ {margin && ({margin}){c.has_gap && ' ⚠'} }
+
+ ) : — }
+
+ )}
+ {c.pct_rev ?? c.pct}%
+ {anyHasCost && (
+
+ {(c.pct_profit || 0) > 0
+ ? {c.pct_profit}%
+ : —
+ }
+
+ )}
+
+ )
+ })}
diff --git a/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx b/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
index 85063b1..22b87c4 100644
--- a/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/ProductPerformance.jsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
+import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Legend } from 'recharts'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -262,6 +262,8 @@ export default function ProductPerformance() {
const [catF, setCatF] = useState('all')
const [chartMode, setChartMode] = useState('revenue')
const [showPrintModal, setShowPrintModal] = useState(false)
+ // 'revenue' | 'units' | 'profit' | 'dual'
+
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
@@ -285,11 +287,24 @@ export default function ProductPerformance() {
const products = data?.products || []
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
+ const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
+ const anyHasCost = products.some(p => (p.total_cost || 0) > 0)
const top10 = [...products]
- .sort((a, b) => chartMode === 'revenue' ? b.revenue - a.revenue : b.qty_sold - a.qty_sold)
+ .sort((a, b) => {
+ if (chartMode === 'units') return b.qty_sold - a.qty_sold
+ if (chartMode === 'profit') return (b.trackable_profit || 0) - (a.trackable_profit || 0)
+ // revenue and dual: sort by revenue
+ return b.revenue - a.revenue
+ })
.slice(0, 10)
- .map((p, i) => ({ name: p.product_name, value: chartMode === 'revenue' ? p.revenue : p.qty_sold, color: CHART_PALETTE[i % CHART_PALETTE.length] }))
+ .map((p, i) => ({
+ name: p.product_name,
+ revenue: p.revenue,
+ profit: p.has_gap ? null : (p.trackable_profit || 0),
+ units: p.qty_sold,
+ color: CHART_PALETTE[i % CHART_PALETTE.length],
+ }))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
@@ -325,35 +340,66 @@ export default function ProductPerformance() {
-
- {[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
- setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}
- ))}
-
- }>
- {top10.length === 0 ? (
-
- ) : (
-
-
-
-
- chartMode === 'revenue' ? '€' + v : String(v)} />
-
- chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
- {top10.map((d, i) => | )}
-
-
-
- )}
-
+ {(() => {
+ const chartModes = [
+ ['revenue', 'Έσοδα'],
+ ['units', 'Τεμάχια'],
+ ...(anyHasCost ? [['profit', 'Κέρδος'], ['dual', 'Έσοδα vs Κέρδος']] : []),
+ ]
+ const subtitleMap = { revenue: 'έσοδα', units: 'τεμάχια', profit: 'κέρδος', dual: 'έσοδα vs κέρδος' }
+ const isDual = chartMode === 'dual'
+ return (
+
+ {chartModes.map(([m, label]) => (
+ setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}
+ ))}
+
+ }>
+ {top10.length === 0 ? (
+
+ ) : (
+
+
+
+
+ chartMode === 'units' ? String(v) : '€' + v} />
+
+ {
+ if (chartMode === 'units') return v + ' τεμ.'
+ return fmtEUR(v)
+ }} />} cursor={{ fill: '#f1f5f9' }} />
+ {isDual && }
+ {isDual ? (
+ <>
+
+
+ >
+ ) : (
+
+ {top10.map((d, i) => (
+ |
+ ))}
+
+ )}
+
+
+
+ )}
+
+ )
+ })()}
{(() => {
- const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
+ const totalCost = products.reduce((s, p) => s + (p.total_cost || 0), 0)
const hasGap = products.some(p => p.has_gap)
+ const overallMargin = totalRev > 0 && !hasGap
+ ? ((totalProfit / totalRev) * 100).toFixed(0) + '%'
+ : null
return (
Προϊόν
Τεμάχια
Έσοδα
+ {anyHasCost && Κόστος }
Κέρδος
Απόβλητα
- % Συνόλου
+ % Έσόδων
+ {anyHasCost && % Κέρδους }
Παραγγελίες
{products.map(p => {
- const pct = totalRev ? (p.revenue / totalRev * 100) : 0
- const margin = p.revenue > 0 && !p.has_gap
+ const pctRev = totalRev ? (p.revenue / totalRev * 100) : 0
+ const pctProfit = totalProfit > 0 && !p.has_gap
+ ? (p.trackable_profit / totalProfit * 100)
+ : null
+ const margin = p.revenue > 0 && !p.has_gap && (p.total_cost || 0) > 0
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
: null
return (
@@ -394,9 +445,19 @@ export default function ProductPerformance() {
{p.product_name}
{fmtNum(p.qty_sold)}
{fmtEUR(p.revenue)}
+ {anyHasCost && (
+
+ {(p.total_cost || 0) > 0
+ ? {fmtEUR(p.total_cost)}
+ : —
+ }
+
+ )}
{p.has_gap ? (
⚠ χωρίς κόστος
+ ) : (p.total_cost || 0) === 0 ? (
+ —
) : (
= 0 ? 'text-green-700' : 'text-red-600'}>
{fmtEUR(p.trackable_profit)}
@@ -412,7 +473,15 @@ export default function ProductPerformance() {
) : — }
- {pct.toFixed(1)}%
+ {pctRev.toFixed(1)}%
+ {anyHasCost && (
+
+ {pctProfit != null
+ ? {pctProfit.toFixed(1)}%
+ : —
+ }
+
+ )}
{fmtNum(p.order_count)}
)
@@ -423,14 +492,26 @@ export default function ProductPerformance() {
Σύνολο
{fmtEUR(totalRev)}
+ {anyHasCost && (
+ {fmtEUR(totalCost)}
+ )}
{hasGap ? (
- {fmtEUR(totalProfit)} {' '}+χωρίς κόστος
- ) : (
- {fmtEUR(totalProfit)}
- )}
+ {fmtEUR(totalProfit)}{' '}+χωρίς κόστος
+ ) : totalProfit > 0 ? (
+
+ {fmtEUR(totalProfit)}
+ {overallMargin && ({overallMargin}) }
+
+ ) : — }
+
100%
+ {anyHasCost && (
+
+ {totalProfit > 0 && !hasGap ? '100%' : '—'}
+
+ )}
diff --git a/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx b/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
index 2afc50d..b7f6014 100644
--- a/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
@@ -27,9 +27,11 @@ export default function RevenueTrends() {
})
const trends = data?.trends || []
+ const anyHasProfit = trends.some(d => (d.profit || 0) > 0)
const chartData = trends.map(d => ({
label: gran === 'monthly' ? d.date : fmtDate(d.date),
revenue: d.revenue,
+ profit: (d.profit || 0) > 0 ? d.profit : undefined,
rolling7: d.rolling7,
}))
@@ -69,6 +71,7 @@ export default function RevenueTrends() {
fmtEUR(v)} />} />
+ {anyHasProfit && }
{gran === 'daily' && }
@@ -83,6 +86,7 @@ export default function RevenueTrends() {
Περίοδος
Παραγγελίες
Έσοδα
+ {anyHasProfit && Κέρδος }
{gran === 'daily' && Μέσος 7 ημ. }
@@ -91,6 +95,17 @@ export default function RevenueTrends() {
{gran === 'monthly' ? d.date : fmtDate(d.date)}
{fmtNum(d.orders)}
{fmtEUR(d.revenue)}
+ {anyHasProfit && (
+
+ {(d.profit || 0) > 0
+ ?
+ {fmtEUR(d.profit)}
+ {d.has_gap && ⚠ }
+
+ : —
+ }
+
+ )}
{gran === 'daily' && {fmtEUR(d.rolling7)} }
))}
diff --git a/manager_dashboard/src/pages/reports/restaurant/Today.jsx b/manager_dashboard/src/pages/reports/restaurant/Today.jsx
index 2e932e3..638351e 100644
--- a/manager_dashboard/src/pages/reports/restaurant/Today.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/Today.jsx
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
-import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset } from 'lucide-react'
+import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset, PiggyBank, ShoppingBag } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
@@ -94,6 +94,22 @@ export default function Today() {
·
{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)} μέσος όρος
+ {bd.trackable_profit > 0 && (
+
+
+
Μεικτό Κέρδος
+
+ {fmtEUR(bd.trackable_profit)}
+
+
+ {bd.revenue > 0 && (
+
+ {((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin
+ {bd.has_gap && ⚠ μερικό }
+
+ )}
+
+ )}
@@ -104,6 +120,17 @@ export default function Today() {
)}
+ {bd.total_cost > 0 && (
+
+ )}
+ {bd.trackable_profit > 0 && (
+
+ )}
@@ -138,11 +165,16 @@ export default function Today() {
# Τραπέζι Άνοιξε Έκλεισε
- Είδη Σύνολο Κατάσταση
+ Είδη Σύνολο Κέρδος Κατάσταση
{orders.slice(0, 30).map(o => {
- const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
+ const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ const costedItems = activeItems.filter(i => i.unit_cost != null)
+ const orderProfit = costedItems.length > 0
+ ? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
+ : null
const isCancelled = o.status === 'cancelled'
return (
@@ -152,6 +184,12 @@ export default function Today() {
{fmtDateTime(o.closed_at)}
{(o.items || []).length}
{fmtEUR(total)}
+
+ {orderProfit != null
+ ? = 0 ? 'text-green-700' : 'text-red-600'}>{fmtEUR(orderProfit)}
+ : —
+ }
+
)
diff --git a/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx b/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
index beccbbf..6c08795 100644
--- a/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
+++ b/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
-import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
@@ -204,9 +204,11 @@ export default function WorkDaySummary() {
const days = data?.business_days || []
+ const anyHasCost = days.some(d => (d.total_cost || 0) > 0)
const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at),
revenue: d.revenue,
+ profit: (d.trackable_profit || 0) > 0 ? d.trackable_profit : undefined,
}))
if (isLoading) return
@@ -229,14 +231,18 @@ export default function WorkDaySummary() {
{chartData.length > 0 && (
-
+
-
+
'€' + v} />
fmtEUR(v)} />} />
-
+ {anyHasCost && }
+
+ {anyHasCost && (
+
+ )}
@@ -256,13 +262,19 @@ export default function WorkDaySummary() {
Διάρκεια
Παραγγελίες
Έσοδα
+ {anyHasCost &&
Κόστος }
+ {anyHasCost &&
Κέρδος }
Ακυρώσεις
Σερβιτόροι
Κατάσταση
- {days.map(d => (
+ {days.map(d => {
+ const margin = d.revenue > 0 && (d.trackable_profit || 0) > 0
+ ? ((d.trackable_profit / d.revenue) * 100).toFixed(0) + '%'
+ : null
+ return (
setDrillDay(d)} striped>
{fmtDate(d.opened_at)}
{fmtTime(d.opened_at)}
@@ -270,6 +282,22 @@ export default function WorkDaySummary() {
{fmtDuration(d.opened_at, d.closed_at)}
{fmtNum(d.order_count)}
{fmtEUR(d.revenue)}
+ {anyHasCost && (
+
+ {(d.total_cost || 0) > 0 ? fmtEUR(d.total_cost) : — }
+
+ )}
+ {anyHasCost && (
+
+ {(d.trackable_profit || 0) > 0 ? (
+
+ {fmtEUR(d.trackable_profit)}
+ {margin && !d.has_gap && ({margin}) }
+ {d.has_gap && ⚠ }
+
+ ) : — }
+
+ )}
{fmtNum(d.cancellation_count)}
{fmtNum(d.waiter_count)}
@@ -286,7 +314,7 @@ export default function WorkDaySummary() {
)}
- ))}
+ )})}
)}