Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:12:23 +03:00
parent defc49f84f
commit bb39088464
78 changed files with 24370 additions and 1358 deletions

View File

@@ -0,0 +1,739 @@
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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
</select>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά ()</label>
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => 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" />
</div>
<div className="flex gap-3 pt-1">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
<button onClick={submit} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
</button>
</div>
</div>
</div>
)
}
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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
</p>
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
</button>
</div>
</div>
</div>
)
}
// Some tables have unpaid items — revenue will be lost, needs hard warning
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="text-red-600 text-lg font-bold">!</span>
</div>
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
<p className="font-semibold">
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
</p>
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
</div>
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
Ακύρωση
</button>
<button onClick={onConfirm} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
</button>
</div>
</div>
</div>
)
}
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 (
<>
<div className="rounded-2xl border overflow-hidden"
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
{/* Header row */}
<div className="flex items-center justify-between px-5 py-3"
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
<div className="flex items-center gap-3">
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: isOpen ? '#16a34a' : '#9ca3af',
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
}} />
<div>
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
</span>
{isOpen && businessDay?.opened_at && (
<span className="text-xs text-gray-500 ml-2">
από {fmtTime(businessDay.opened_at)}
</span>
)}
</div>
</div>
<div className="flex gap-2">
{isOpen && waitersWithoutShift.length > 0 && (
<button
onClick={() => setShowStartShift(true)}
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
>
+ Βάρδια
</button>
)}
{isOpen ? (
<button
onClick={() => handleCloseDay(false)}
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
>
Κλείσιμο Ημέρας
</button>
) : (
<button
onClick={() => openDayMut.mutate()}
disabled={openDayMut.isPending}
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
>
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
</button>
)}
</div>
</div>
{/* Active shifts */}
{isOpen && (
<div className="px-5 py-3 border-t border-gray-100 bg-white">
{activeShifts.length === 0 ? (
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
) : (
<div className="flex flex-wrap gap-2">
{activeShifts.map(s => (
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
<div>
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
{s.total_collected > 0 && (
<span className="text-xs text-green-700 ml-2 font-medium">{s.total_collected.toFixed(2)}</span>
)}
</div>
<button
onClick={() => handleEndShift(s.id, s.waiter_name)}
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
title="Τέλος βάρδιας"
>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{showStartShift && (
<StartShiftModal
waiters={waitersWithoutShift}
onClose={() => setShowStartShift(false)}
onStart={handleStartShift}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => 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 (
<img
src={waiter.avatarUrl}
alt={waiter.name}
style={{
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── 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 (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
flexShrink: 0,
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: '14px 0 0 14px',
}} />
{/* Header: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: '#111315',
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row */}
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
{hasPendingPrint && (
<span style={{
fontSize: 11, fontWeight: 700,
background: '#92400e', color: '#fcd34d',
borderRadius: 999, padding: '2px 8px',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
Εκκρεμής εκτύπωση
</span>
)}
</div>
{/* Stats row */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
</div>
</button>
)
}
// ─── 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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
}
return (
<div className="space-y-6">
<BusinessDayPanel />
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
<div className="flex gap-2">
{FILTERS.map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
>
{FILTER_LABELS[f]}
</button>
))}
</div>
</div>
{filtered.length === 0 && (
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{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 (
<TableCardV1
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={amount}
openedAt={order?.opened_at ?? null}
waiters={waiterNames}
hasPendingPrint={hasPendingPrint}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
/>
)
})}
</div>
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
{pendingPrintOrders.length > 0 && (
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
<div className="flex items-center gap-3">
<span style={{ fontSize: 20 }}></span>
<div>
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
<p className="text-xs text-orange-700 mt-0.5">
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
</p>
</div>
</div>
<button
className="btn btn-primary text-sm"
style={{ background: '#c2410c', borderColor: '#c2410c' }}
onClick={retryAllOrders}
disabled={retryingId !== null}
>
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
</button>
</div>
<div className="divide-y divide-orange-50">
{pendingPrintOrders.map(({ table, order }) => {
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
const tableName = table.label || `T${table.number}`
return (
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
{tableName}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-800">
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
</p>
<p className="text-xs text-gray-500 truncate">
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
className="btn btn-secondary text-xs"
onClick={() => navigate(`/orders/${order.id}`)}
>
Λεπτομέρειες
</button>
<button
className="btn btn-primary text-xs"
style={{ background: '#c2410c', borderColor: '#c2410c' }}
onClick={() => retrySingleOrder(order.id)}
disabled={retryingId === order.id}
>
{retryingId === order.id ? '…' : 'Εκτύπωση'}
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}