feat: add payroll + tables served to shift detail and workday staff views

- business_day summary: waiter entries now include hourly_rate_snapshot, duration_hours,
  shift_pay (cumulative across day's shifts), tables_served (unique table count)
- ShiftDetailModal: 4-KPI row replaced with 2×4 grid —
  Row 1: Διάρκεια | Μισθός/Ώρα | Πληρωμή Βάρδιας | Αρχικά Μετρητά
  Row 2: Σύνολο Παραγγελιών | Εισπράξεις | Προς Παράδοση | Εξ. Τραπεζιών
- WorkdaySummaryModal WaitersTab: collapsed row uses ΠΑΡΑΓΓΕΛΙΕΣ | ΕΞ.ΤΡΑΠΕΖΙΩΝ |
  ΑΝΤΙΚ.ΕΙΣΠΡ. | ΕΙΣΠΡΑΞΗ; expanded grid is 2 rows:
  Row 1: ΕΝΑΡΞΗ | ΛΗΞΗ | ΜΙΣΘΟΣ/ΩΡΑ | ΜΙΣΘΟΣ (εως τώρα)
  Row 2: ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ | ΤΑΜΕΙΟ ΤΩΡΑ | ΑΝΤΙΚ.ΕΙΣΠΡ. | ΕΞ.ΤΡΑΠΕΖΙΩΝ

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:24:01 +03:00
parent e5b8ef32be
commit b15fb27a37
3 changed files with 108 additions and 42 deletions

View File

@@ -246,14 +246,25 @@ def business_day_summary(
"shift_started_at": None,
"shift_ended_at": None,
"starting_cash": None,
# Phase 2B payroll
"hourly_rate_snapshot": None,
"duration_hours": None,
"shift_pay": None,
# distinct tables served (not order count)
"tables_served": 0,
}
return waiter_stats[uid]
# track unique tables per waiter (opened_by)
waiter_tables: dict = {} # uid → set of table_ids
for o in orders:
if o.status == "cancelled":
continue
e = _waiter_entry(o.opened_by)
e["orders_opened"] += 1
if o.table_id:
waiter_tables.setdefault(o.opened_by, set()).add(o.table_id)
for item in all_items:
if item.status == "cancelled":
@@ -281,6 +292,7 @@ def business_day_summary(
e["other"] += val
for shift in shifts:
from routers.shifts import compute_shift_pay
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"]):
e["shift_started_at"] = _dt(shift.started_at)
@@ -290,6 +302,18 @@ def business_day_summary(
ended = _dt(shift.ended_at)
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
e["shift_ended_at"] = ended
# payroll — take the most recent non-null snapshot
if shift.hourly_rate_snapshot is not None:
e["hourly_rate_snapshot"] = shift.hourly_rate_snapshot
pay_data = compute_shift_pay(shift)
e["duration_hours"] = round((e["duration_hours"] or 0) + pay_data["duration_hours"], 2)
if pay_data["shift_pay"] is not None:
e["shift_pay"] = round((e["shift_pay"] or 0) + pay_data["shift_pay"], 2)
# write tables_served counts
for uid, table_set in waiter_tables.items():
if uid in waiter_stats:
waiter_stats[uid]["tables_served"] = len(table_set)
# manager gets shift = day open/close
manager_opener = users_map.get(day.opened_by_id)

View File

@@ -170,25 +170,8 @@ function OverviewTab({ data }) {
function WaitersTab({ data }) {
const [expanded, setExpanded] = useState(null)
// Compute the widest rendered string for each of the 4 metric columns so we
// can give every row the same fixed width — no DOM measurement needed, just
// pick the longest formatted value across all waiters and use ch units.
const colWidths = data.waiters.reduce(
(acc, w) => ({
orders: Math.max(acc.orders, String(w.orders_opened).length),
items: Math.max(acc.items, String(w.items_ordered).length),
value: Math.max(acc.value, fmt(w.total_items_value).length),
collected: Math.max(acc.collected, fmt(w.total_collected).length),
}),
{ orders: 4, items: 4, value: 8, collected: 8 }
)
// Convert char-count to px using ~8.5px per char at font-size 16, plus label + padding
const colPx = {
orders: Math.max(colWidths.orders * 9 + 16, 72),
items: Math.max(colWidths.items * 9 + 16, 72),
value: Math.max(colWidths.value * 8 + 16, 96),
collected: Math.max(colWidths.collected * 8 + 16, 96),
}
// Fixed column widths for the collapsed summary row
const colPx = { orders: 88, tables: 88, items: 80, collected: 104 }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -198,6 +181,10 @@ function WaitersTab({ data }) {
{data.waiters.map(w => {
const badge = roleBadge(w.role)
const isOpen = expanded === w.waiter_id
// Compute current cash in drawer: starting_cash + collected so far
const drawerNow = (w.starting_cash ?? 0) + (w.total_collected ?? 0)
return (
<div key={w.waiter_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
@@ -228,17 +215,16 @@ function WaitersTab({ data }) {
}}>{badge.label}</span>
</div>
{/* 4 fixed-width metric columns — same width for every row */}
{/* 4 fixed-width metric columns */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{[
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
{ label: 'ΑΝΤΙΚΕΙΜ.', value: w.items_ordered, width: colPx.items, color: '#111315' },
{ label: 'ΑΞΙΑ ΠΑΡ.', value: fmt(w.total_items_value),width: colPx.value, color: '#374151' },
{ label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ', value: w.tables_served ?? 0, width: colPx.tables, color: '#111315' },
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, width: colPx.items, color: '#111315' },
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
].map(col => (
<div key={col.label} style={{
width: col.width, flexShrink: 0, textAlign: 'right',
padding: '0 6px',
width: col.width, flexShrink: 0, textAlign: 'right', padding: '0 6px',
}}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap' }}>{col.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: col.color, whiteSpace: 'nowrap' }}>{col.value}</div>
@@ -255,22 +241,56 @@ function WaitersTab({ data }) {
</button>
{isOpen && (
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 4 cells, full-width grid, equal columns */}
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Row 1: shift timing + payroll */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, color: '#111315' },
{ label: 'ΕΝΑΡΞΗ ΒΑΡΔΙΑΣ', value: fmtTime(w.shift_started_at), color: '#111315' },
{
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
color: w.shift_ended_at ? '#111315' : '#16a34a',
},
{
label: 'ΜΙΣΘΟΣ / ΩΡΑ',
value: w.hourly_rate_snapshot != null ? fmt(w.hourly_rate_snapshot) : '—',
color: '#111315',
},
{
label: 'ΜΙΣΘΟΣ (εως τώρα)',
value: w.shift_pay != null ? fmt(w.shift_pay) : 'Δεν παρακολ.',
color: w.shift_pay != null ? '#059669' : '#9ca3af',
},
].map(cell => (
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
</div>
))}
</div>
{/* Row 2: cash + collections */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{
label: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
color: '#111315',
},
{
label: 'ΤΑΜΕΙΟ ΤΩΡΑ',
value: fmt(drawerNow),
color: '#3758c9',
},
{
label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.',
value: w.items_paid,
color: '#111315',
},
{
label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ',
value: w.tables_served ?? 0,
color: '#111315',
},
].map(cell => (
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>

View File

@@ -177,20 +177,42 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
{summary && (
<>
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
].map(k => (
{/* KPI grid — 2 rows × 4 cols */}
{(() => {
const totalOrders = summary.orders?.length ?? 0
const tableSet = new Set(summary.orders?.map(o => o.table_name).filter(Boolean))
const tablesServed = tableSet.size
const durationHours = summary.duration_hours ?? (summary.duration_minutes != null ? summary.duration_minutes / 60 : null)
const durationLabel = durationHours != null
? (() => { const h = Math.floor(durationHours); const m = Math.round((durationHours - h) * 60); return h > 0 ? `${h}ω ${m}λ` : `${m}λ` })()
: fmtMins(summary.duration_minutes)
const row1 = [
{ label: 'Διάρκεια Βάρδιας', value: durationLabel },
{ label: 'Μισθός / Ώρα', value: summary.hourly_rate_snapshot != null ? fmtEUR(summary.hourly_rate_snapshot) : '—' },
{ label: 'Πληρωμή Βάρδιας', value: summary.shift_pay != null ? fmtEUR(summary.shift_pay) : 'Δεν παρακολουθείται', accent: summary.shift_pay != null ? '#059669' : undefined },
{ label: 'Αρχικά Μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
]
const row2 = [
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Προς Παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
{ label: 'Εξ. Τραπεζιών', value: String(tablesServed) },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[row1, row2].map((row, ri) => (
<div key={ri} style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 6 }}>
{row.map(k => (
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
</div>
))}
</div>
))}
</div>
)
})()}
{/* Colour legend */}
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>