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:
@@ -246,14 +246,25 @@ def business_day_summary(
|
|||||||
"shift_started_at": None,
|
"shift_started_at": None,
|
||||||
"shift_ended_at": None,
|
"shift_ended_at": None,
|
||||||
"starting_cash": 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]
|
return waiter_stats[uid]
|
||||||
|
|
||||||
|
# track unique tables per waiter (opened_by)
|
||||||
|
waiter_tables: dict = {} # uid → set of table_ids
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
if o.status == "cancelled":
|
if o.status == "cancelled":
|
||||||
continue
|
continue
|
||||||
e = _waiter_entry(o.opened_by)
|
e = _waiter_entry(o.opened_by)
|
||||||
e["orders_opened"] += 1
|
e["orders_opened"] += 1
|
||||||
|
if o.table_id:
|
||||||
|
waiter_tables.setdefault(o.opened_by, set()).add(o.table_id)
|
||||||
|
|
||||||
for item in all_items:
|
for item in all_items:
|
||||||
if item.status == "cancelled":
|
if item.status == "cancelled":
|
||||||
@@ -281,6 +292,7 @@ def business_day_summary(
|
|||||||
e["other"] += val
|
e["other"] += val
|
||||||
|
|
||||||
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)
|
||||||
@@ -290,6 +302,18 @@ def business_day_summary(
|
|||||||
ended = _dt(shift.ended_at)
|
ended = _dt(shift.ended_at)
|
||||||
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
|
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
|
||||||
e["shift_ended_at"] = ended
|
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 gets shift = day open/close
|
||||||
manager_opener = users_map.get(day.opened_by_id)
|
manager_opener = users_map.get(day.opened_by_id)
|
||||||
|
|||||||
@@ -170,25 +170,8 @@ function OverviewTab({ data }) {
|
|||||||
function WaitersTab({ data }) {
|
function WaitersTab({ data }) {
|
||||||
const [expanded, setExpanded] = useState(null)
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
|
||||||
// Compute the widest rendered string for each of the 4 metric columns so we
|
// Fixed column widths for the collapsed summary row
|
||||||
// can give every row the same fixed width — no DOM measurement needed, just
|
const colPx = { orders: 88, tables: 88, items: 80, collected: 104 }
|
||||||
// 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),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@@ -198,6 +181,10 @@ function WaitersTab({ data }) {
|
|||||||
{data.waiters.map(w => {
|
{data.waiters.map(w => {
|
||||||
const badge = roleBadge(w.role)
|
const badge = roleBadge(w.role)
|
||||||
const isOpen = expanded === w.waiter_id
|
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 (
|
return (
|
||||||
<div key={w.waiter_id} style={{
|
<div key={w.waiter_id} style={{
|
||||||
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
|
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
|
||||||
@@ -228,17 +215,16 @@ function WaitersTab({ data }) {
|
|||||||
}}>{badge.label}</span>
|
}}>{badge.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4 fixed-width metric columns — same width for every row */}
|
{/* 4 fixed-width metric columns */}
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
|
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
|
||||||
{ label: 'ΑΝΤΙΚΕΙΜ.', value: w.items_ordered, width: colPx.items, color: '#111315' },
|
{ label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ', value: w.tables_served ?? 0, width: colPx.tables, color: '#111315' },
|
||||||
{ label: 'ΑΞΙΑ ΠΑΡ.', value: fmt(w.total_items_value),width: colPx.value, color: '#374151' },
|
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, width: colPx.items, color: '#111315' },
|
||||||
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
|
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
|
||||||
].map(col => (
|
].map(col => (
|
||||||
<div key={col.label} style={{
|
<div key={col.label} style={{
|
||||||
width: col.width, flexShrink: 0, textAlign: 'right',
|
width: col.width, flexShrink: 0, textAlign: 'right', padding: '0 6px',
|
||||||
padding: '0 6px',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap' }}>{col.label}</div>
|
<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>
|
<div style={{ fontSize: 15, fontWeight: 800, color: col.color, whiteSpace: 'nowrap' }}>{col.value}</div>
|
||||||
@@ -255,22 +241,56 @@ function WaitersTab({ data }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{/* 4 cells, full-width grid, equal columns */}
|
{/* Row 1: shift timing + payroll */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
<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: fmtTime(w.shift_started_at), color: '#111315' },
|
||||||
{
|
{
|
||||||
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
|
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
|
||||||
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
|
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
|
||||||
color: w.shift_ended_at ? '#111315' : '#16a34a',
|
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: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
|
label: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
|
||||||
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
|
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
|
||||||
color: '#111315',
|
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 => (
|
].map(cell => (
|
||||||
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
|
<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: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
|
||||||
|
|||||||
@@ -177,20 +177,42 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
|||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<>
|
<>
|
||||||
{/* KPI row */}
|
{/* KPI grid — 2 rows × 4 cols */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
{(() => {
|
||||||
{[
|
const totalOrders = summary.orders?.length ?? 0
|
||||||
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
|
const tableSet = new Set(summary.orders?.map(o => o.table_name).filter(Boolean))
|
||||||
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
|
const tablesServed = tableSet.size
|
||||||
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
|
const durationHours = summary.duration_hours ?? (summary.duration_minutes != null ? summary.duration_minutes / 60 : null)
|
||||||
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
|
const durationLabel = durationHours != null
|
||||||
].map(k => (
|
? (() => { const h = Math.floor(durationHours); const m = Math.round((durationHours - h) * 60); return h > 0 ? `${h}ω ${m}λ` : `${m}λ` })()
|
||||||
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
|
: fmtMins(summary.duration_minutes)
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
|
const row1 = [
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
|
{ 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: 16, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
{/* Colour legend */}
|
{/* Colour legend */}
|
||||||
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user