import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import toast from 'react-hot-toast' import client from '../api/client' // ─── Business Day + Shift Management Panel ─────────────────────────────────── function fmtTime(iso) { if (!iso) return '—' return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) } function fmtShiftDuration(iso) { if (!iso) return '' const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000) if (mins < 60) return `${mins}λ` const h = Math.floor(mins / 60); const m = mins % 60 return m === 0 ? `${h}ω` : `${h}ω ${m}λ` } function StartShiftModal({ waiters, onClose, onStart }) { const [waiterId, setWaiterId] = useState('') const [cash, setCash] = useState('') const [busy, setBusy] = useState(false) async function submit() { if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return } setBusy(true) try { await onStart(Number(waiterId), cash ? parseFloat(cash) : null) onClose() } catch (e) { toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας') } finally { setBusy(false) } } return (
{ if (e.target === e.currentTarget) onClose() }}>

Έναρξη Βάρδιας

setCash(e.target.value)} className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
) } function CloseConfirmModal({ details, onClose, onConfirm, busy }) { const hasPendingPayments = details.partially_paid > 0 if (!hasPendingPayments) { // All tables open but nothing owed — safe to close, just needs confirmation return (
{ if (e.target === e.currentTarget) onClose() }}>

Κλείσιμο Ημέρας

{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}

Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;

) } // Some tables have unpaid items — revenue will be lost, needs hard warning return (
{ if (e.target === e.currentTarget) onClose() }}>
!

Εκκρεμείς Πληρωμές

{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'}, από τα οποία {details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές.

Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.

Επιλέξτε Ακύρωση για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
) } function BusinessDayPanel() { const qc = useQueryClient() const [showStartShift, setShowStartShift] = useState(false) const [closeDetails, setCloseDetails] = useState(null) const [forceClosing, setForceClosing] = useState(false) const { data: businessDay } = useQuery({ queryKey: ['business-day'], queryFn: () => client.get('/api/business-day/current').then(r => r.data), refetchInterval: 15_000, }) const { data: activeShifts = [] } = useQuery({ queryKey: ['active-shifts'], queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []), refetchInterval: 15_000, }) const { data: allWaiters = [] } = useQuery({ queryKey: ['waiters'], queryFn: () => client.get('/api/waiters/').then(r => r.data), staleTime: 60_000, }) const waitersWithoutShift = allWaiters.filter( w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id) ) const openDayMut = useMutation({ mutationFn: () => client.post('/api/business-day/open', {}), onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) }, onError: (e) => toast.error(e.response?.data?.detail || 'Σφάλμα'), }) async function handleCloseDay(force = false) { setForceClosing(force) try { await client.post('/api/business-day/close', { force }) toast.success('Ημέρα έκλεισε!') setCloseDetails(null) qc.invalidateQueries({ queryKey: ['business-day'] }) qc.invalidateQueries({ queryKey: ['active-shifts'] }) qc.invalidateQueries({ queryKey: ['orders-active'] }) } catch (e) { const detail = e.response?.data?.detail if (e.response?.status === 409 && detail?.open_orders) { setCloseDetails(detail) } else { toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος') } } finally { setForceClosing(false) } } async function handleEndShift(shiftId, waiterName) { if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return try { await client.post(`/api/shifts/manager/end/${shiftId}`, {}) toast.success('Βάρδια έκλεισε') qc.invalidateQueries({ queryKey: ['active-shifts'] }) } catch (e) { toast.error(e.response?.data?.detail || 'Σφάλμα') } } async function handleStartShift(waiterId, startingCash) { await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash }) toast.success('Βάρδια ξεκίνησε!') qc.invalidateQueries({ queryKey: ['active-shifts'] }) } const isOpen = !!businessDay return ( <>
{/* Header row */}
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'} {isOpen && businessDay?.opened_at && ( από {fmtTime(businessDay.opened_at)} )}
{isOpen && waitersWithoutShift.length > 0 && ( )} {isOpen ? ( ) : ( )}
{/* Active shifts */} {isOpen && (
{activeShifts.length === 0 ? (

Κανένας σερβιτόρος σε βάρδια

) : (
{activeShifts.map(s => (
{s.waiter_name} {fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)} {s.total_collected > 0 && ( €{s.total_collected.toFixed(2)} )}
))}
)}
)}
{showStartShift && ( setShowStartShift(false)} onStart={handleStartShift} /> )} {closeDetails && ( setCloseDetails(null)} onConfirm={() => handleCloseDay(true)} busy={forceClosing} /> )} ) } const API_URL = import.meta.env.VITE_API_URL || '' const FILTERS = ['all', 'open', 'partially_paid', 'free'] const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' } // ─── Design tokens ──────────────────────────────────────────────────────────── const COLORS = { open: { label: 'Ανοιχτό', tint: '#eef7f0', tintStrong: '#d7ecdc', accent: '#2f9e5e', ink: '#1f7042', }, partially_paid: { label: 'Μερική πληρ.', tint: '#f4eefb', tintStrong: '#e3d4f3', accent: '#7a44c9', ink: '#57309a', }, free: { label: 'Ελεύθερο', tint: '#f4f4f2', tintStrong: '#dfe2e6', accent: '#8a9099', ink: '#5a6169', }, } // ─── Helpers ────────────────────────────────────────────────────────────────── function formatEuro(n) { return '€' + parseFloat(n).toFixed(2) } function formatDuration(openedAt) { const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) if (mins < 60) return `${mins}m` const h = Math.floor(mins / 60) const m = mins % 60 return m === 0 ? `${h}h` : `${h}h ${m}m` } function occupiedMinsFromDate(openedAt) { return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) } function orderTotal(items = []) { return items .filter(i => i.status !== 'cancelled') .reduce((s, i) => s + i.unit_price * i.quantity, 0) } function avatarColor(name) { const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'] let h = 0 for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0 return palette[h % palette.length] } function WaiterBubble({ waiter, size = 26 }) { // waiter: { name, avatarUrl } if (waiter.avatarUrl) { return ( {waiter.name} ) } const parts = waiter.name.trim().split(' ') const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase() return (
{initials}
) } // ─── V1 Table Card ──────────────────────────────────────────────────────────── function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) { const s = COLORS[status] || COLORS.free const [hover, setHover] = useState(false) const [pressed, setPressed] = useState(false) const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null const showMulti = waiters.length >= 3 return ( ) } // ─── Page ───────────────────────────────────────────────────────────────────── export default function DashboardPage() { const [filter, setFilter] = useState('all') const [retryingId, setRetryingId] = useState(null) const navigate = useNavigate() const queryClient = useQueryClient() const { data: tables = [], isLoading: tablesLoading } = useQuery({ queryKey: ['tables'], queryFn: () => client.get('/api/tables/').then(r => r.data), refetchInterval: 5_000, }) const { data: orders = [], isLoading: ordersLoading } = useQuery({ queryKey: ['orders-active'], queryFn: () => client.get('/api/orders/').then(r => r.data), refetchInterval: 5_000, }) const { data: waiters = [] } = useQuery({ queryKey: ['waiters'], queryFn: () => client.get('/api/waiters/').then(r => r.data), staleTime: 60_000, }) // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl } const waiterMap = Object.fromEntries(waiters.map(w => { const name = w.full_name || w.nickname || w.username const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username) const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null return [w.id, { name, shortName, avatarUrl }] })) const tableCards = tables.map(table => { const order = orders.find(o => o.table_id === table.id && ['open', 'partially_paid'].includes(o.status) ) const tableStatus = order ? order.status : 'free' const hasPendingPrint = order ? order.items.some(i => i.status === 'active' && !i.printed) : false return { table, order, tableStatus, hasPendingPrint } }) const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint) async function retrySingleOrder(orderId) { setRetryingId(orderId) try { const res = await client.post(`/api/orders/${orderId}/retry-print`) const results = res.data.print_results ?? [] const allOk = results.length === 0 || results.every(r => r.success) if (allOk) { toast.success('Εκτυπώθηκε επιτυχώς') } else { const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ') toast.error(`Αποτυχία: ${failed}`) } queryClient.invalidateQueries({ queryKey: ['orders-active'] }) } catch { toast.error('Σφάλμα επικοινωνίας') } finally { setRetryingId(null) } } async function retryAllOrders() { for (const { order } of pendingPrintOrders) { if (order) await retrySingleOrder(order.id) } } const filtered = filter === 'all' ? tableCards : tableCards.filter(c => c.tableStatus === filter) if (tablesLoading || ordersLoading) { return
Φόρτωση…
} return (

Dashboard

{FILTERS.map(f => ( ))}
{filtered.length === 0 && (

Δεν βρέθηκαν τραπέζια.

)}
{filtered.map(({ table, order, tableStatus, hasPendingPrint }) => { const waiterNames = order ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null }) : [] const amount = order ? orderTotal(order.items) : null return ( navigate(`/orders/${order.id}`) : undefined} /> ) })}
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */} {pendingPrintOrders.length > 0 && (

Εκκρεμείς Εκτυπώσεις

{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ

{pendingPrintOrders.map(({ table, order }) => { const unprinted = order.items.filter(i => i.status === 'active' && !i.printed) const tableName = table.label || `T${table.number}` return (
{tableName}

{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν

{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}

) })}
)}
) }