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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:58:55 +03:00
parent 34b58b3f2e
commit 5da378a0ae
10 changed files with 673 additions and 330 deletions

View File

@@ -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)

View File

@@ -148,7 +148,7 @@ function OverviewTab({ data }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Top stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />

View File

@@ -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 */}
<div className="flex-1 flex overflow-hidden">
{/* LEFT: product info */}
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3 overflow-y-auto">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
{/* LEFT: tabbed product info — 30% width */}
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: '25%' }}>
{/* Left tab bar */}
<div className="flex border-b border-gray-200 shrink-0 bg-white">
{[
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'digital', label: 'Digital Menu' },
{ key: 'cost', label: 'Κόστος' },
].map(t => (
<button key={t.key} onClick={() => 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}
</button>
))}
</div>
{/* Description — optional, for digital menus / staff info */}
<div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
rows={3}
value={form.description}
onChange={e => setField('description', e.target.value)}
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
{/* Left tab content */}
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-3">
<div>
<label className="label">Τιμή βάσης () *</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
{/* Phase 2A — Cost section */}
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Κόστος</span>
<div className="flex gap-1">
{['simple', 'detailed'].map(mode => (
<button key={mode} type="button"
onClick={() => 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' ? 'Απλό' : 'Ανάλυση'}
</button>
))}
{/* Tab 1: Main Info */}
{leftTab === 'info' && (<>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
</div>
</div>
<div className="p-3 space-y-2">
{form.cost_mode === 'simple' ? (
<div>
<label className="label text-xs">Κόστος ανά μονάδα ()</label>
<PriceInput value={form.cost_simple} onChange={v => 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 (
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
</p>
)
})()}
</div>
) : (
<div className="space-y-2">
{form.cost_breakdown.map((entry, bi) => (
<div key={bi} className="flex gap-2 items-center">
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
value={entry.label}
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
<PriceInput value={entry.amount}
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
className="w-28" />
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0"></button>
</div>
))}
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
{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 (
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
Σύνολο: {total.toFixed(2)}
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
{isError && <span className="ml-1"> Κόστος > Τιμή!</span>}
<div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
rows={3}
value={form.description}
onChange={e => setField('description', e.target.value)}
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
<div>
<label className="label">Βασική Τιμή () *</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
<option value=""> Χωρίς κατηγορία </option>
{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 [
<option key={parent.id} value={parent.id}>{parent.name}</option>,
...subs.map(s => <option key={s.id} value={s.id}>&nbsp;&nbsp; {s.name}</option>),
]
})}
</select>
</div>
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
<option value=""> Χωρίς εκτυπωτή </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<button type="button" onClick={() => 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'
}`}>
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
</button>
<div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
)}
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</>)}
{/* 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 (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Ορατότητα</p>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_visible}
onChange={e => setField('digital_visible', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Show on digital menu</span>
</label>
<div>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_available}
onChange={e => setField('digital_available', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Available for ordering</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-7">Uncheck to show as &lsquo;Out of stock&rsquo;</p>
</div>
)
})()}
</div>
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">Display overrides</p>
<p className="text-xs text-gray-400 mb-3">Leave blank to use the standard product value</p>
<div className="space-y-3">
<div>
<label className="label">Display name (override)</label>
<input className="input" value={form.digital_name}
onChange={e => setField('digital_name', e.target.value)}
placeholder={form.name || 'Leave blank to use product name'} />
</div>
<div>
<label className="label">Description</label>
<textarea className="input resize-none" rows={3}
value={form.digital_description}
onChange={e => setField('digital_description', e.target.value)}
placeholder="Leave blank to use product description"
style={{ lineHeight: 1.5, fontSize: 13 }} />
</div>
<div>
<label className="label">Image URL</label>
<input className="input" value={form.digital_image_url}
onChange={e => setField('digital_image_url', e.target.value)}
placeholder="https://…" />
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Pricing</p>
<div className="space-y-3">
<div>
<label className="label">Online price (override)</label>
<input type="number" step="0.01" min="0" className="input w-40"
value={form.digital_price}
onChange={e => setField('digital_price', e.target.value)}
placeholder="e.g. 9.50" />
</div>
<div>
<label className="label">Discount %</label>
<input type="number" step="1" min="0" max="100" className="input w-40"
value={form.digital_discount}
onChange={e => setField('digital_discount', e.target.value)}
placeholder="e.g. 10 for 10% off" />
</div>
<div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800">
Customer sees: <span className="font-bold">{customerPrice.toFixed(2)}</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
})()}
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
<option value=""> Χωρίς κατηγορία </option>
{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 [
<option key={parent.id} value={parent.id}>{parent.name}</option>,
...subs.map(s => <option key={s.id} value={s.id}>&nbsp;&nbsp; {s.name}</option>),
]
})}
</select>
</div>
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
<option value=""> Χωρίς εκτυπωτή </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<button type="button" onClick={() => 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'
}`}>
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
</button>
{/* Image upload */}
<div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
{/* Tab 3: Cost */}
{leftTab === 'cost' && (
<div className="space-y-3">
<p className="text-xs text-gray-400">Το κόστος καταγράφεται στιγμιότυπα σε κάθε παραγγελία για υπολογισμό κέρδους.</p>
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Τύπος κόστους</span>
<div className="flex gap-1">
{['simple', 'detailed'].map(mode => (
<button key={mode} type="button"
onClick={() => 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' ? 'Απλό' : 'Ανάλυση'}
</button>
))}
</div>
</div>
<div className="p-3 space-y-2">
{form.cost_mode === 'simple' ? (
<div>
<label className="label text-xs">Κόστος ανά μονάδα ()</label>
<PriceInput value={form.cost_simple} onChange={v => 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 (
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
</p>
)
})()}
</div>
) : (
<div className="space-y-2">
{form.cost_breakdown.map((entry, bi) => (
<div key={bi} className="flex gap-2 items-center">
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
value={entry.label}
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
<PriceInput value={entry.amount}
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
className="w-28" />
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0"></button>
</div>
))}
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
{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 (
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
Σύνολο: {total.toFixed(2)}
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
{isError && <span className="ml-1"> Κόστος &gt; Τιμή!</span>}
</div>
)
})()}
</div>
)}
</div>
</div>
</div>
)}
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</div>
{/* RIGHT: tabs */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* RIGHT: ingredient/option tabs — 70% */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
{tabs.map(tab => {
if (tab.isAdd) return (
@@ -794,94 +907,6 @@ export default function ProductFormModal({ product, categories, printers, onSave
</div>
)}
{/* 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 (
<div className="space-y-6">
{/* Section 1 — Visibility */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Ορατότητα</p>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_visible}
onChange={e => setField('digital_visible', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Show on digital menu</span>
</label>
<div>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_available}
onChange={e => setField('digital_available', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Available for ordering</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-7">Uncheck to show as &lsquo;Out of stock&rsquo;</p>
</div>
</div>
</div>
{/* Section 2 — Display overrides */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">Display overrides</p>
<p className="text-xs text-gray-400 mb-3">Leave blank to use the standard product value</p>
<div className="space-y-3">
<div>
<label className="label">Display name (override)</label>
<input className="input" value={form.digital_name}
onChange={e => setField('digital_name', e.target.value)}
placeholder={form.name || 'Leave blank to use product name'} />
</div>
<div>
<label className="label">Description</label>
<textarea className="input resize-none" rows={3}
value={form.digital_description}
onChange={e => setField('digital_description', e.target.value)}
placeholder="Leave blank to use product description"
style={{ lineHeight: 1.5, fontSize: 13 }} />
</div>
<div>
<label className="label">Image URL</label>
<input className="input" value={form.digital_image_url}
onChange={e => setField('digital_image_url', e.target.value)}
placeholder="https://…" />
</div>
</div>
</div>
{/* Section 3 — Pricing */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Pricing</p>
<div className="space-y-3">
<div>
<label className="label">Online price (override)</label>
<input type="number" step="0.01" min="0" className="input w-40"
value={form.digital_price}
onChange={e => setField('digital_price', e.target.value)}
placeholder="e.g. 9.50" />
</div>
<div>
<label className="label">Discount %</label>
<input type="number" step="1" min="0" max="100" className="input w-40"
value={form.digital_discount}
onChange={e => setField('digital_discount', e.target.value)}
placeholder="e.g. 10 for 10% off" />
</div>
<div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800">
Customer sees: <span className="font-bold">{customerPrice.toFixed(2)}</span>
</div>
</div>
</div>
</div>
)
})()}
{/* Preference set tab */}
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
const si = activeTab

View File

@@ -903,10 +903,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
Επεξεργασία
</button>
{/* Delete — pill style */}
{/* Delete — icon only, same height as Edit pill */}
<button onClick={e => { 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="Διαγραφή">
<Icon name="trash" size={13} />
Διαγραφή
</button>
</div>
</div>

View File

@@ -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
</div>
</div>
{/* Column 3: PIN */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 12, background: '#fafafa' }}>
{/* Column 3: Payroll + PIN */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
<div>
<SectionLabel>Μισθοδοσία</SectionLabel>
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number" step="0.50" min="0"
style={{ ...inputStyle, width: 110 }}
placeholder="π.χ. 5.50"
value={form.hourly_rate ?? ''}
onChange={e => f('hourly_rate', e.target.value)}
/>
{form.hourly_rate && parseFloat(form.hourly_rate) > 0 && (
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
</span>
)}
{!form.hourly_rate && (
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
)}
</div>
</FormField>
</div>
<SectionLabel>Κωδικός PIN</SectionLabel>
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
@@ -725,11 +748,14 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
<div>
<SectionLabel>Πληροφορίες</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<InfoBlock label="Ημερομηνία δημιουργίας"
value={waiter.created_at ? new Date(waiter.created_at).toLocaleDateString('el-GR') : null} />
<InfoBlock label="Κατάσταση"
value={waiter.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
valueColor={waiter.is_active ? '#16a34a' : '#ef4444'} />
{/* Row 1: Date + Status side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<InfoBlock label="Εγγραφή"
value={waiter.created_at ? new Date(waiter.created_at).toLocaleDateString('el-GR') : null} />
<InfoBlock label="Κατάσταση"
value={waiter.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
valueColor={waiter.is_active ? '#16a34a' : '#ef4444'} />
</div>
<InfoBlock label="Ρόλος"
value={waiter.role === 'waiter' ? 'Σερβιτόρος' : 'Διαχειριστής'} />
{waiter.role === 'waiter' && (

View File

@@ -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() {
<>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-5">
<Panel title="Κατανομή Εσόδων">
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
</PieChart>
</ResponsiveContainer>
</div>
<Panel title={pieMetric === 'profit' ? 'Κατανομή Κέρδους' : 'Κατανομή Εσόδων'} right={
anyHasCost ? (
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['profit', 'Κέρδος']].map(([m, label]) => (
<button key={m} onClick={() => 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}</button>
))}
</div>
) : null
}>
{pieData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-[12px] text-slate-400">Δεν υπάρχουν δεδομένα κέρδους</div>
) : (
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</Panel>
</div>
<div className="col-span-7 grid grid-cols-2 gap-4">
{categories.map((c, i) => (
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
</div>
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
<span className="font-mono">{c.pct_rev ?? c.pct}%</span>
</div>
{(c.trackable_profit || 0) > 0 && (
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
<span className="text-[11px] font-medium text-green-700">{fmtEUR(c.trackable_profit)} κέρδος</span>
{margin && <span className="text-[10px] text-slate-400">{margin}{c.has_gap && ' ⚠'}</span>}
</div>
)}
</div>
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
<span className="font-mono">{c.pct}%</span>
</div>
</div>
))}
)
})}
</div>
</div>
<div className="mt-4">
<Panel title="Κατηγορίες" padded={false}>
<DataTable>
<THead><TH>Κατηγορία</TH><TH align="right">Προϊόντα</TH><TH align="right">Τεμάχια</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">% Συνόλου</TH></THead>
<THead>
<TH>Κατηγορία</TH>
<TH align="right">Προϊόντα</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">% Εσόδων</TH>
{anyHasCost && <TH align="right">% Κέρδους</TH>}
</THead>
<tbody>
{categories.map((c, i) => (
<TR key={c.category_id} striped>
<TD><span className="inline-flex items-center gap-2 font-medium text-slate-900"><span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />{c.category_name}</span></TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
<TD mono align="right">{c.pct}%</TD>
</TR>
))}
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<TR key={c.category_id} striped>
<TD><span className="inline-flex items-center gap-2 font-medium text-slate-900"><span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />{c.category_name}</span></TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(c.total_cost || 0) > 0 ? fmtEUR(c.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(c.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(c.trackable_profit)}
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin}){c.has_gap && ' ⚠'}</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
)}
<TD mono align="right">{c.pct_rev ?? c.pct}%</TD>
{anyHasCost && (
<TD mono align="right">
{(c.pct_profit || 0) > 0
? <span className="text-green-700">{c.pct_profit}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
</TR>
)
})}
</tbody>
</DataTable>
</Panel>

View File

@@ -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() {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`} right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
<button key={m} onClick={() => 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}</button>
))}
</div>
}>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>{top10.map((d, i) => <Cell key={i} fill={d.color} />)}</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
{(() => {
const chartModes = [
['revenue', 'Έσοδα'],
['units', 'Τεμάχια'],
...(anyHasCost ? [['profit', 'Κέρδος'], ['dual', 'Έσοδα vs Κέρδος']] : []),
]
const subtitleMap = { revenue: 'έσοδα', units: 'τεμάχια', profit: 'κέρδος', dual: 'έσοδα vs κέρδος' }
const isDual = chartMode === 'dual'
return (
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${subtitleMap[chartMode] || 'έσοδα'}`} right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{chartModes.map(([m, label]) => (
<button key={m} onClick={() => 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}</button>
))}
</div>
}>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<div style={{ height: isDual ? 320 : 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: isDual ? 16 : 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false}
tickFormatter={v => chartMode === 'units' ? String(v) : '€' + v} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={(v, name) => {
if (chartMode === 'units') return v + ' τεμ.'
return fmtEUR(v)
}} />} cursor={{ fill: '#f1f5f9' }} />
{isDual && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
{isDual ? (
<>
<Bar dataKey="revenue" name="Έσοδα" fill="#60a5fa" radius={[0, 0, 0, 0]} barSize={10} />
<Bar dataKey="profit" name="Κέρδος" fill="#34d399" radius={[0, 3, 3, 0]} barSize={10} />
</>
) : (
<Bar dataKey={chartMode === 'units' ? 'units' : chartMode === 'profit' ? 'profit' : 'revenue'}
radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => (
<Cell key={i} fill={chartMode === 'profit' ? '#34d399' : d.color} />
))}
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
)
})()}
<div className="mt-4">
{(() => {
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 (
<Panel
title="Όλα τα Προϊόντα"
@@ -378,15 +424,20 @@ export default function ProductPerformance() {
<TH>Προϊόν</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
<TH align="right">Κέρδος</TH>
<TH align="right">Απόβλητα</TH>
<TH align="right">% Συνόλου</TH>
<TH align="right">% Έσόδων</TH>
{anyHasCost && <TH align="right">% Κέρδους</TH>}
<TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{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() {
<TD className="font-medium text-slate-900">{p.product_name}</TD>
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
{anyHasCost && (
<TD mono align="right">
{(p.total_cost || 0) > 0
? <span className="text-slate-600">{fmtEUR(p.total_cost)}</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">
{p.has_gap ? (
<span className="text-amber-500 text-[11px]"> χωρίς κόστος</span>
) : (p.total_cost || 0) === 0 ? (
<span className="text-slate-300"></span>
) : (
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
{fmtEUR(p.trackable_profit)}
@@ -412,7 +473,15 @@ export default function ProductPerformance() {
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{pctRev.toFixed(1)}%</TD>
{anyHasCost && (
<TD mono align="right">
{pctProfit != null
? <span className="text-green-700">{pctProfit.toFixed(1)}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
@@ -423,14 +492,26 @@ export default function ProductPerformance() {
<TR>
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-slate-600">{fmtEUR(totalCost)}</TD>
)}
<TD mono align="right" className="font-bold">
{hasGap ? (
<span className="text-amber-600">{fmtEUR(totalProfit)} {' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : (
<span className="text-green-700">{fmtEUR(totalProfit)}</span>
)}
<span className="text-amber-600">{fmtEUR(totalProfit)}{' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : totalProfit > 0 ? (
<span className="text-green-700">
{fmtEUR(totalProfit)}
{overallMargin && <span className="ml-1 text-[10px] text-slate-400">({overallMargin})</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD />
<TD mono align="right" className="font-bold">100%</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-green-700">
{totalProfit > 0 && !hasGap ? '100%' : '—'}
</TD>
)}
<TD />
</TR>
</tfoot>

View File

@@ -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() {
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
{anyHasProfit && <Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} connectNulls={false} />}
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
</LineChart>
</ResponsiveContainer>
@@ -83,6 +86,7 @@ export default function RevenueTrends() {
<TH>Περίοδος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasProfit && <TH align="right">Κέρδος</TH>}
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
</THead>
<tbody>
@@ -91,6 +95,17 @@ export default function RevenueTrends() {
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
<TD mono align="right">{fmtNum(d.orders)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
{anyHasProfit && (
<TD mono align="right">
{(d.profit || 0) > 0
? <span className="text-green-700">
{fmtEUR(d.profit)}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
</TR>
))}

View File

@@ -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() {
<span>·</span>
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
</div>
{bd.trackable_profit > 0 && (
<div className="mt-4 flex items-center gap-3 border-t border-sky-100 pt-4">
<div className="flex-1">
<div className="text-[10px] font-medium uppercase tracking-[0.08em] text-slate-400">Μεικτό Κέρδος</div>
<div className="font-mono text-[22px] font-semibold tabular-nums text-green-700">
{fmtEUR(bd.trackable_profit)}
</div>
</div>
{bd.revenue > 0 && (
<div className="rounded-md bg-green-50 px-2.5 py-1 text-[13px] font-semibold text-green-700 ring-1 ring-inset ring-green-200">
{((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin
{bd.has_gap && <span className="ml-1 text-[10px] text-amber-500"> μερικό</span>}
</div>
)}
</div>
)}
</div>
<div className="col-span-7 grid grid-cols-3 gap-4">
@@ -104,6 +120,17 @@ export default function Today() {
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
)}
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
{bd.total_cost > 0 && (
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
)}
{bd.trackable_profit > 0 && (
<StatCard
label="Μεικτό Κέρδος"
value={fmtEUR(bd.trackable_profit)}
sub={bd.has_gap ? '⚠ μερικό κέρδος' : `${((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin`}
icon={PiggyBank}
/>
)}
</div>
</div>
@@ -138,11 +165,16 @@ export default function Today() {
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right">Κέρδος</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{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 (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
@@ -152,6 +184,12 @@ export default function Today() {
<TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD mono align="right">
{orderProfit != null
? <span className={orderProfit >= 0 ? 'text-green-700' : 'text-red-600'}>{fmtEUR(orderProfit)}</span>
: <span className="text-slate-300"></span>
}
</TD>
<TD><StatusBadge status={o.status} pulse /></TD>
</TR>
)

View File

@@ -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 <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
@@ -229,14 +231,18 @@ export default function WorkDaySummary() {
<div className="flex-1 overflow-y-auto p-6">
{chartData.length > 0 && (
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
<div style={{ height: 220 }}>
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: anyHasCost ? 16 : 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Line type="monotone" dataKey="revenue" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
{anyHasCost && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
{anyHasCost && (
<Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} activeDot={{ r: 5 }} connectNulls={false} />
)}
</LineChart>
</ResponsiveContainer>
</div>
@@ -256,13 +262,19 @@ export default function WorkDaySummary() {
<TH align="right">Διάρκεια</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH>
<TH className="w-10" />
</THead>
<tbody>
{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 (
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
<TD mono>{fmtTime(d.opened_at)}</TD>
@@ -270,6 +282,22 @@ export default function WorkDaySummary() {
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
<TD mono align="right">{fmtNum(d.order_count)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(d.total_cost || 0) > 0 ? fmtEUR(d.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(d.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(d.trackable_profit)}
{margin && !d.has_gap && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
)}
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
<TD><StatusBadge status={d.status} pulse /></TD>
@@ -286,7 +314,7 @@ export default function WorkDaySummary() {
)}
</TD>
</TR>
))}
)})}
</tbody>
</DataTable>
)}