feat: Phase 2K — business day close summary (expanded)
Backend — /api/business-day/summary now returns full financial summary: - COGS: trackable cost (sum of unit_cost snapshots on paid items), uncosted item count and revenue, gross_profit, cogs_has_gap flag - Labor: total_labor_cost (sum of shift_pay for the day), labor_untracked_shifts count - Waste: waste_item_count + waste_estimated_cost from waste_log for this business day - Expenses: expenses_total, expenses_paid_today, expenses_due for expenses logged today - Tabbed revenue: revenue from items with status='tabbed' (deferred, not yet collected) - Net estimate: revenue - COGS - labor - waste - expenses_paid; net_has_unknowns flag when any component has gaps (uncosted items or untracked shifts) - compute_shift_pay import hoisted out of loop (bug fix) Frontend — WorkdaySummaryModal OverviewTab fully expanded: - New FinancialRow helper for consistent two-column P&L rows with accent/warn/indent - REVENUE section: total sales, tabbed deduction, net collected - COGS section: trackable cost, uncosted items warning, gross profit with gap indicator - LABOR section: tracked labor cost, untracked shifts warning - WASTE section: item count + estimated cost (hidden when zero) - EXPENSES section: total, paid today, still due (hidden when zero) - NET ESTIMATE: prominent bottom line, amber warning when unknowns exist - Payment breakdown bar (existing) - Cash reconciliation summary when store cash was counted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -300,8 +300,8 @@ def business_day_summary(
|
|||||||
else:
|
else:
|
||||||
e["other"] += val
|
e["other"] += val
|
||||||
|
|
||||||
|
from routers.shifts import compute_shift_pay
|
||||||
for shift in shifts:
|
for shift in shifts:
|
||||||
from routers.shifts import compute_shift_pay
|
|
||||||
e = _waiter_entry(shift.waiter_id)
|
e = _waiter_entry(shift.waiter_id)
|
||||||
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
|
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
|
||||||
e["shift_started_at"] = _dt(shift.started_at)
|
e["shift_started_at"] = _dt(shift.started_at)
|
||||||
@@ -422,6 +422,62 @@ def business_day_summary(
|
|||||||
total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts)
|
total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts)
|
||||||
store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected
|
store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected
|
||||||
|
|
||||||
|
# ── Phase 2K: full financial summary ──────────────<E29480><E29480>─────────────────────
|
||||||
|
|
||||||
|
# COGS from Phase 2A cost snapshots on paid items
|
||||||
|
from models.waste import WasteLog
|
||||||
|
from models.expenses import Expense
|
||||||
|
from models.tabs import Tab, TabEntry
|
||||||
|
|
||||||
|
cogs_trackable = 0.0
|
||||||
|
cogs_uncosted_revenue = 0.0
|
||||||
|
cogs_uncosted_count = 0
|
||||||
|
for item in paid_items:
|
||||||
|
rev = item.unit_price * item.quantity
|
||||||
|
if item.unit_cost is not None:
|
||||||
|
cogs_trackable += item.unit_cost * item.quantity
|
||||||
|
else:
|
||||||
|
cogs_uncosted_revenue += rev
|
||||||
|
cogs_uncosted_count += 1
|
||||||
|
gross_profit = round(total_revenue - cogs_trackable, 2)
|
||||||
|
cogs_has_gap = cogs_uncosted_count > 0
|
||||||
|
|
||||||
|
# LABOR from shifts
|
||||||
|
total_labor_cost = 0.0
|
||||||
|
labor_untracked_shifts = 0
|
||||||
|
for shift in shifts:
|
||||||
|
pay_data = compute_shift_pay(shift)
|
||||||
|
if pay_data["shift_pay"] is not None:
|
||||||
|
total_labor_cost += pay_data["shift_pay"]
|
||||||
|
else:
|
||||||
|
labor_untracked_shifts += 1
|
||||||
|
|
||||||
|
# WASTE from Phase 2H
|
||||||
|
waste_entries = db.query(WasteLog).filter(WasteLog.business_day_id == day.id).all()
|
||||||
|
waste_item_count = len(waste_entries)
|
||||||
|
waste_estimated_cost = round(sum(w.total_cost for w in waste_entries if w.total_cost is not None), 2)
|
||||||
|
|
||||||
|
# EXPENSES logged today
|
||||||
|
expenses_today = db.query(Expense).filter(Expense.business_day_id == day.id).all()
|
||||||
|
expenses_total = round(sum(e.total_amount for e in expenses_today), 2)
|
||||||
|
expenses_paid_today = round(sum(e.paid_amount for e in expenses_today), 2)
|
||||||
|
expenses_due = round(expenses_total - expenses_paid_today, 2)
|
||||||
|
|
||||||
|
# TABBED REVENUE (items moved to tabs — not yet collected)
|
||||||
|
tabbed_items = [i for i in all_items if i.status == "tabbed"]
|
||||||
|
tabbed_revenue = round(sum(i.unit_price * i.quantity for i in tabbed_items), 2)
|
||||||
|
|
||||||
|
# NET estimate (revenue - COGS - labor - waste - expenses paid)
|
||||||
|
net_estimate = round(
|
||||||
|
total_revenue
|
||||||
|
- cogs_trackable
|
||||||
|
- total_labor_cost
|
||||||
|
- waste_estimated_cost
|
||||||
|
- expenses_paid_today,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
net_has_unknowns = cogs_has_gap or labor_untracked_shifts > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"day_id": day.id,
|
"day_id": day.id,
|
||||||
"status": day.status,
|
"status": day.status,
|
||||||
@@ -438,12 +494,28 @@ def business_day_summary(
|
|||||||
"with_unpaid_items": with_unpaid_items,
|
"with_unpaid_items": with_unpaid_items,
|
||||||
"active_shifts": active_shift_count,
|
"active_shifts": active_shift_count,
|
||||||
},
|
},
|
||||||
# Phase 2E
|
# Phase 2E — cash reconciliation
|
||||||
"store_opening_cash": day.store_opening_cash,
|
"store_opening_cash": day.store_opening_cash,
|
||||||
"store_closing_cash": day.store_closing_cash,
|
"store_closing_cash": day.store_closing_cash,
|
||||||
"store_cash_discrepancy": day.store_cash_discrepancy,
|
"store_cash_discrepancy": day.store_cash_discrepancy,
|
||||||
"store_expected_closing": round(store_expected_closing, 2),
|
"store_expected_closing": round(store_expected_closing, 2),
|
||||||
"total_waiter_collected": round(total_waiter_collected, 2),
|
"total_waiter_collected": round(total_waiter_collected, 2),
|
||||||
|
# Phase 2K — full financial summary
|
||||||
|
"cogs_trackable": round(cogs_trackable, 2),
|
||||||
|
"cogs_uncosted_revenue": round(cogs_uncosted_revenue, 2),
|
||||||
|
"cogs_uncosted_count": cogs_uncosted_count,
|
||||||
|
"cogs_has_gap": cogs_has_gap,
|
||||||
|
"gross_profit": gross_profit,
|
||||||
|
"total_labor_cost": round(total_labor_cost, 2),
|
||||||
|
"labor_untracked_shifts": labor_untracked_shifts,
|
||||||
|
"waste_item_count": waste_item_count,
|
||||||
|
"waste_estimated_cost": waste_estimated_cost,
|
||||||
|
"expenses_total": expenses_total,
|
||||||
|
"expenses_paid_today": expenses_paid_today,
|
||||||
|
"expenses_due": expenses_due,
|
||||||
|
"tabbed_revenue": tabbed_revenue,
|
||||||
|
"net_estimate": net_estimate,
|
||||||
|
"net_has_unknowns": net_has_unknowns,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,38 +122,149 @@ function PaymentBar({ w, total }) {
|
|||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FinancialRow({ label, value, sub, accent, warn, indent = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||||
|
padding: indent ? '4px 0 4px 16px' : '5px 0',
|
||||||
|
borderBottom: indent ? 'none' : '1px solid #f9fafb',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: indent ? 12 : 13, color: warn ? '#d97706' : indent ? '#6b7280' : '#374151', flex: 1 }}>
|
||||||
|
{warn && <span style={{ marginRight: 5 }}>⚠</span>}{label}
|
||||||
|
</span>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: indent ? 12 : 13.5, fontWeight: indent ? 500 : 700, color: accent || '#111315', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{sub && <div style={{ fontSize: 10.5, color: '#9ca3af' }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function OverviewTab({ data }) {
|
function OverviewTab({ data }) {
|
||||||
const waiters = data.waiters.filter(w => w.role === 'waiter')
|
const waiters = data.waiters.filter(w => w.role === 'waiter')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
|
{/* Top stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
|
||||||
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
|
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
|
||||||
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
|
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
|
||||||
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />
|
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />
|
||||||
<StatPill label="Σερβιτόροι" value={String(waiters.length)} accent="#0d7a8a" />
|
<StatPill label="Σερβιτόροι" value={String(waiters.length)} accent="#0d7a8a" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Hours */}
|
||||||
<SectionTitle>Ώρα Λειτουργίας</SectionTitle>
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
|
||||||
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '12px 16px', flex: 1, minWidth: 150 }}>
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΑΝΟΙΓΜΑ</div>
|
||||||
<div style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, marginBottom: 2 }}>ΑΝΟΙΓΜΑ</div>
|
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>{fmtDateTime(data.opened_at)}</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{fmtDateTime(data.opened_at)}</div>
|
</div>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΚΛΕΙΣΙΜΟ</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>
|
||||||
|
{data.closed_at ? fmtDateTime(data.closed_at) : <span style={{ color: '#16a34a' }}>Ημέρα Ενεργή</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '12px 16px', flex: 1, minWidth: 150 }}>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, marginBottom: 2 }}>ΚΛΕΙΣΙΜΟ</div>
|
</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700 }}>
|
|
||||||
{data.closed_at
|
{/* Full P&L */}
|
||||||
? fmtDateTime(data.closed_at)
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
: <span style={{ color: '#16a34a' }}>Ημέρα Ενεργή</span>}
|
{/* REVENUE */}
|
||||||
|
<div style={{ padding: '10px 16px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Έσοδα</SectionTitle>
|
||||||
|
<FinancialRow label="Σύνολο πωλήσεων" value={fmt(data.total_revenue)} accent="#111315" />
|
||||||
|
{data.tabbed_revenue > 0 && (
|
||||||
|
<FinancialRow label="Καρτέλες (μη εισπραχθέντα)" value={`− ${fmt(data.tabbed_revenue)}`} accent="#d97706" indent />
|
||||||
|
)}
|
||||||
|
<FinancialRow
|
||||||
|
label="Καθαρά εισπραχθέντα"
|
||||||
|
value={fmt(data.total_revenue - (data.tabbed_revenue || 0))}
|
||||||
|
accent="#16a34a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COGS */}
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Κόστος Πωληθέντων (COGS)</SectionTitle>
|
||||||
|
<FinancialRow label="Κόστος καταγεγραμμένων" value={fmt(data.cogs_trackable)} accent="#374151" />
|
||||||
|
{data.cogs_has_gap && (
|
||||||
|
<FinancialRow
|
||||||
|
label={`${data.cogs_uncosted_count} αντ. χωρίς κόστος (${fmt(data.cogs_uncosted_revenue)} έσοδα)`}
|
||||||
|
value="?" warn indent
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FinancialRow
|
||||||
|
label="Μικτό κέρδος"
|
||||||
|
value={fmt(data.gross_profit)}
|
||||||
|
accent={data.gross_profit >= 0 ? '#16a34a' : '#dc2626'}
|
||||||
|
warn={data.cogs_has_gap}
|
||||||
|
sub={data.cogs_has_gap ? 'Μερικό — υπάρχουν αντ. χωρίς κόστος' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LABOR */}
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Μισθοδοσία</SectionTitle>
|
||||||
|
<FinancialRow label="Κόστος εργασίας (καταγεγρ.)" value={fmt(data.total_labor_cost)} accent="#374151" />
|
||||||
|
{data.labor_untracked_shifts > 0 && (
|
||||||
|
<FinancialRow label={`${data.labor_untracked_shifts} βάρδιες χωρίς ωριαία αμοιβή`} value="?" warn indent />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WASTE */}
|
||||||
|
{(data.waste_item_count > 0) && (
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Αποβλήτα</SectionTitle>
|
||||||
|
<FinancialRow
|
||||||
|
label={`${data.waste_item_count} καταχωρήσεις`}
|
||||||
|
value={fmt(data.waste_estimated_cost)}
|
||||||
|
accent="#d97706"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EXPENSES */}
|
||||||
|
{data.expenses_total > 0 && (
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Έξοδα (σήμερα)</SectionTitle>
|
||||||
|
<FinancialRow label="Σύνολο εξόδων" value={fmt(data.expenses_total)} accent="#374151" />
|
||||||
|
<FinancialRow label="Πλη<CEBB><CEB7>ωμένα σήμερα" value={fmt(data.expenses_paid_today)} accent="#374151" indent />
|
||||||
|
{data.expenses_due > 0 && (
|
||||||
|
<FinancialRow label="Ακόμη οφείλονται" value={fmt(data.expenses_due)} accent="#dc2626" warn indent />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NET */}
|
||||||
|
<div style={{ padding: '12px 16px', background: '#f9fafb' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>
|
||||||
|
Εκτιμώμενο Καθαρό Αποτέλεσμα
|
||||||
|
</div>
|
||||||
|
{data.net_has_unknowns && (
|
||||||
|
<div style={{ fontSize: 11, color: '#d97706', marginTop: 1 }}>
|
||||||
|
⚠ Μερικό — υπάρχουν άγνωστα κόστη
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 20, fontWeight: 800,
|
||||||
|
color: data.net_estimate >= 0 ? '#16a34a' : '#dc2626',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{fmt(data.net_estimate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment breakdown */}
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>Κατανομή Πληρωμών</SectionTitle>
|
<SectionTitle>Κατανομή Πληρωμών</SectionTitle>
|
||||||
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '16px 18px' }}>
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px' }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 }
|
const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 }
|
||||||
for (const w of data.waiters) {
|
for (const w of data.waiters) {
|
||||||
@@ -163,6 +274,23 @@ function OverviewTab({ data }) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cash reconciliation summary (if counted) */}
|
||||||
|
{data.store_closing_cash != null && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Ταμείο</SectionTitle>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<FinancialRow label="Αναμενόμενο ταμείο" value={fmt(data.store_expected_closing)} />
|
||||||
|
<FinancialRow label="Μετρημένο ταμείο" value={fmt(data.store_closing_cash)} />
|
||||||
|
<FinancialRow
|
||||||
|
label="Διαφορά"
|
||||||
|
value={data.store_cash_discrepancy === 0 ? '✓ Εντάξει' : fmt(data.store_cash_discrepancy)}
|
||||||
|
accent={data.store_cash_discrepancy === 0 ? '#16a34a' : '#dc2626'}
|
||||||
|
warn={data.store_cash_discrepancy !== 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user