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

@@ -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: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
{ 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 => (
<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>
{/* 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: 16, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
</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' }}>