diff --git a/manager_dashboard/src/components/WorkdaySummaryModal.jsx b/manager_dashboard/src/components/WorkdaySummaryModal.jsx new file mode 100644 index 0000000..88882e5 --- /dev/null +++ b/manager_dashboard/src/components/WorkdaySummaryModal.jsx @@ -0,0 +1,659 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../api/client' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fmt(n) { + if (n == null) return '—' + return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +} + +function fmtTime(iso) { + if (!iso) return '—' + return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) +} + +function fmtDateTime(iso) { + if (!iso) return '—' + const d = new Date(iso) + return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +function roleBadge(role) { + if (role === 'manager' || role === 'sysadmin') { + return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' } + } + return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' } +} + +// All payment methods with their display config +const PAY_METHODS = [ + { key: 'cash', label: 'Μετρητά', color: '#16a34a', bg: '#dcfce7' }, + { key: 'card', label: 'Κάρτα', color: '#2563eb', bg: '#dbeafe' }, + { key: 'transfer', label: 'Τρ. Μεταφ.', color: '#7c3aed', bg: '#ede9fe' }, + { key: 'treat', label: 'Κεράστηκε', color: '#b45309', bg: '#fef3c7' }, + { key: 'other', label: 'Άλλο', color: '#6b7280', bg: '#f3f4f6' }, +] + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function TabBtn({ label, active, onClick, badge, warn }) { + return ( + + ) +} + +function StatPill({ label, value, accent = '#374151' }) { + return ( +
+ {label} + {value} +
+ ) +} + +function SectionTitle({ children }) { + return ( +
{children}
+ ) +} + +function PaymentBar({ w, total }) { + if (!total) return null + const bars = PAY_METHODS.filter(m => (w[m.key] || 0) > 0) + if (bars.length === 0) return null + return ( +
+
+ {bars.map(m => ( +
+ ))} +
+
+ {bars.map(m => ( + + ● {m.label} {fmt(w[m.key])} + + ))} +
+
+ ) +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── + +function OverviewTab({ data }) { + const waiters = data.waiters.filter(w => w.role === 'waiter') + return ( +
+
+ + + + +
+ +
+ Ώρα Λειτουργίας +
+
+
ΑΝΟΙΓΜΑ
+
{fmtDateTime(data.opened_at)}
+
+
+
ΚΛΕΙΣΙΜΟ
+
+ {data.closed_at + ? fmtDateTime(data.closed_at) + : Ημέρα Ενεργή} +
+
+
+
+ +
+ Κατανομή Πληρωμών +
+ {(() => { + const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 } + for (const w of data.waiters) { + for (const k of Object.keys(totals)) totals[k] += (w[k] || 0) + } + return + })()} +
+
+
+ ) +} + +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), + } + + return ( +
+ {data.waiters.length === 0 && ( +
Δεν βρέθηκαν σερβιτόροι
+ )} + {data.waiters.map(w => { + const badge = roleBadge(w.role) + const isOpen = expanded === w.waiter_id + return ( +
+ + + {isOpen && ( +
+ {/* 4 cells, full-width grid, equal columns */} +
+ {[ + { 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.starting_cash != null ? fmt(w.starting_cash) : '—', + color: '#111315', + }, + ].map(cell => ( +
+
{cell.label}
+
{cell.value}
+
+ ))} +
+ +
+ )} +
+ ) + })} +
+ ) +} + +function ZonesTab({ data }) { + const maxRev = Math.max(...data.zones.map(z => z.revenue), 1) + return ( +
+ {data.zones.length === 0 && ( +
Δεν βρέθηκαν ζώνες
+ )} + {data.zones.map(z => ( +
+
+
+ {z.group_name} + {fmt(z.revenue)} +
+ +
+
+
+ +
+
+ ΠΑΡΑΓΓΕΛΙΕΣ + {z.orders} +
+
+ ΑΝΤΙΚΕΙΜΕΝΑ + {z.items} +
+ {z.orders > 0 && ( +
+ Μ.Ο./ΠΑΡΑΓ. + {fmt(z.revenue / z.orders)} +
+ )} +
+
+ ))} +
+ ) +} + +function PrintersTab({ data }) { + return ( +
+ {data.printers.length === 0 && ( +
Δεν βρέθηκαν δεδομένα εκτύπωσης
+ )} + {data.printers.map(p => { + const successRate = p.jobs > 0 ? Math.round((p.success / p.jobs) * 100) : 0 + return ( +
+
+
+ + + + + +
+
+
{p.printer_name}
+
{p.ip_address}
+
+
+
{fmt(p.items_value)}
+
αξία εκτυπ.
+
+ = 80 ? '#fef9c3' : '#fee2e2', + color: successRate === 100 ? '#16a34a' : successRate >= 80 ? '#a16207' : '#dc2626', + }}>{successRate}% +
+ +
+
+
+
+
+
+ +
+
ΕΡΓΑΣΙΕΣ {p.jobs}
+
ΕΠΙΤΥΧΕΙΣ {p.success}
+ {p.failed > 0 &&
ΑΠΟΤΥΧΕΣ {p.failed}
} +
+
+ ) + })} +
+ ) +} + +function ChecksTab({ data, onForceClose, closing, isOpen }) { + const { checks } = data + const allGood = checks.open_orders === 0 + const hasUnpaid = checks.with_unpaid_items > 0 + const hasActiveShifts = checks.active_shifts > 0 + + return ( +
+ +
0 ? '#fecaca' : '#bbf7d0'}`, + borderRadius: 12, padding: '16px 18px', background: checks.open_orders > 0 ? '#fff5f5' : '#f0fdf4', + display: 'flex', alignItems: 'flex-start', gap: 12, + }}> +
0 ? '#fee2e2' : '#dcfce7', + display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, + }}> + {checks.open_orders > 0 ? '⚠️' : '✓'} +
+
+
Ανοιχτά Τραπέζια
+ {checks.open_orders > 0 ? ( +
+ {checks.open_orders} τραπέζια ακόμα ανοιχτά + {hasUnpaid && ` · ${checks.with_unpaid_items} με απλήρωτα αντικείμενα`} +
+ ) : ( +
Όλα τα τραπέζια έχουν κλείσει
+ )} +
+
+ +
+
+ {hasActiveShifts ? '⏳' : '✓'} +
+
+
Ενεργές Βάρδιες
+ {hasActiveShifts ? ( +
+ {checks.active_shifts} βάρδι{checks.active_shifts === 1 ? 'α' : 'ες'} ακόμα ενεργ{checks.active_shifts === 1 ? 'ή' : 'ές'} — θα κλείσουν αυτόματα +
+ ) : ( +
Δεν υπάρχουν ενεργές βάρδιες
+ )} +
+
+ + {allGood ? ( +
+ +
+ Ο έλεγχος πέρασε — η ημέρα είναι έτοιμη να κλείσει. +
+
+ ) : hasUnpaid ? ( +
+
+ Προσοχή: Υπάρχουν απλήρωτα αντικείμενα. Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά δεν θα καταγραφούν στις αναφορές. +
+
+ ) : ( +
+
+ Υπάρχουν ανοιχτά τραπέζια χωρίς απλήρωτα αντικείμενα. Θα κλείσουν αυτόματα. +
+
+ )} + + {isOpen && ( + + )} +
+ ) +} + +// ── Main modal ──────────────────────────────────────────────────────────────── + +export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = false, onDayClosed }) { + const [tab, setTab] = useState('overview') + const [closing, setClosing] = useState(false) + + const { data, isLoading, error } = useQuery({ + queryKey: ['workday-summary', dayId ?? 'current'], + queryFn: () => client.get('/api/business-day/summary' + (dayId ? `?day_id=${dayId}` : '')).then(r => r.data), + refetchInterval: showCloseAction ? 15_000 : false, + }) + + async function handleClose(force = false) { + setClosing(true) + try { + await client.post('/api/business-day/close', { force }) + onDayClosed?.() + } catch (e) { + const detail = e.response?.data?.detail + if (e.response?.status === 409 && detail?.open_orders) { + setTab('checks') + } else { + toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος') + } + } finally { + setClosing(false) + } + } + + const tabs = [ + { id: 'overview', label: 'Επισκόπηση' }, + { id: 'waiters', label: 'Προσωπικό', badge: data?.waiters?.length }, + { id: 'zones', label: 'Ζώνες', badge: data?.zones?.length }, + { id: 'printers', label: 'Εκτυπωτές', badge: data?.printers?.length }, + ...(showCloseAction ? [{ + id: 'checks', label: 'Έλεγχοι', + badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null, + warn: data?.checks?.with_unpaid_items > 0, + }] : []), + ] + + const isOpen = data?.status === 'open' + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* Header */} +
+
+
+ + + + + +
+
+
+ {showCloseAction ? 'Κλείσιμο Εργάσιμης Ημέρας' : 'Στατιστικά Ημέρας'} +
+ {data && ( +
+ {data.status === 'open' + ? ● Ημέρα Ενεργή + : Κλειστή + } + {' · '}Άνοιγμα {fmtDateTime(data?.opened_at)} +
+ )} +
+ +
+ +
+ {tabs.map(t => ( + setTab(t.id)} badge={t.badge} warn={t.warn} /> + ))} +
+
+ + {/* Body */} +
+ {isLoading && ( +
Φόρτωση…
+ )} + {error && ( +
Σφάλμα φόρτωσης δεδομένων
+ )} + {data && ( + <> + {tab === 'overview' && } + {tab === 'waiters' && } + {tab === 'zones' && } + {tab === 'printers' && } + {tab === 'checks' && ( + handleClose(data.checks.open_orders > 0)} + closing={closing} + /> + )} + + )} +
+ + {/* Footer — only shown when in close-flow and not already on checks tab */} + {showCloseAction && data && tab !== 'checks' && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx index 47640f0..d717536 100644 --- a/manager_dashboard/src/pages/DashboardPage.jsx +++ b/manager_dashboard/src/pages/DashboardPage.jsx @@ -7,6 +7,7 @@ import Badge from '../ui/Badge' import { ConfirmModal } from '../ui/Modal' import { LicenseContext } from '../layouts/AppLayout' import ShiftDetailModal from './reports/shared/ShiftDetailModal' +import WorkdaySummaryModal from '../components/WorkdaySummaryModal' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -259,7 +260,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) { onClick={e => { if (e.target === e.currentTarget) onClose() }} >
{isOpen && activeItems.length > 0 && ( )} @@ -406,23 +407,23 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) { )} {printers.length > 0 && ( -
+
onChange(fromLocal(e.target.value))} + style={{ + width: '100%', height: 38, borderRadius: 8, + border: '1px solid #dfe2e6', padding: '0 12px', + fontSize: 14, fontFamily: 'inherit', marginBottom: 8, + }} + /> +
+ {quickOffsets.map(o => ( + + ))} +
+
+ ) +} + +function GroupedTableSelect({ tables, value, onChange }) { + // Group tables by group_id + const groups = {} + for (const t of tables) { + const key = t.group?.name || 'Χωρίς ζώνη' + if (!groups[key]) groups[key] = [] + groups[key].push(t) + } + return ( + + ) +} + +function ReservationModal({ tables, initial, onClose, onSaved }) { + const isEdit = !!initial?.id + const [guestName, setGuestName] = useState(initial?.guest_name ?? '') + const [partySize, setPartySize] = useState(initial?.party_size ?? 2) + const [phone, setPhone] = useState(initial?.phone ?? '') + const [email, setEmail] = useState(initial?.email ?? '') + const [note, setNote] = useState(initial?.note ?? '') + const [tableId, setTableId] = useState(initial?.table_id ?? null) + const [reservedFor, setReservedFor] = useState( + initial?.reserved_for ? new Date(initial.reserved_for) : new Date() + ) + + const saveMut = useMutation({ + mutationFn: (payload) => isEdit + ? client.patch(`/api/reservations/${initial.id}`, payload) + : client.post('/api/reservations/', payload), + onSuccess: () => { toast.success(isEdit ? 'Αποθηκεύτηκε!' : 'Κράτηση δημιουργήθηκε!'); onSaved() }, + onError: () => toast.error('Σφάλμα αποθήκευσης'), + }) + + function submit(e) { + e.preventDefault() + if (!guestName.trim()) return toast.error('Εισάγετε όνομα') + if (partySize < 1) return toast.error('Αριθμός ατόμων > 0') + saveMut.mutate({ + guest_name: guestName.trim(), + party_size: partySize, + phone: phone.trim() || null, + email: email.trim() || null, + note: note.trim() || null, + table_id: tableId, + reserved_for: toLocalISO(reservedFor), + }) + } + + const labelStyle = { fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, display: 'block' } + const inputStyle = { width: '100%', height: 38, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box' } + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + style={{ + position: 'fixed', inset: 0, zIndex: 9000, + background: 'rgba(0,0,0,0.4)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 16, + }} + > +
+ {/* Header */} +
+
+ {isEdit ? 'Επεξεργασία Κράτησης' : 'Νέα Κράτηση'} +
+ +
+ +
+ + {/* Name + Party size on same row */} +
+
+ + setGuestName(e.target.value)} + placeholder="Ονοματεπώνυμο πελάτη" + required + /> +
+
+ + setPartySize(Number(e.target.value))} + /> +
+
+ + {/* Date/time */} +
+ + +
+ + {/* Table */} +
+ + +
+ + {/* Phone + Email */} +
+
+ + setPhone(e.target.value)} placeholder="69xxxxxxxx" /> +
+
+ + setEmail(e.target.value)} placeholder="email@..." /> +
+
+ + {/* Note */} +
+ +