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() }}>
Έναρξη Βάρδιας
✕
Σερβιτόρος
setWaiterId(e.target.value)}>
— Επιλέξτε —
{waiters.map(w => {w.full_name || w.username} )}
Αρχικά Μετρητά (€)
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" />
Ακύρωση
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
)
}
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 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;
Ακύρωση
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
)
}
// 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 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές .
Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.
Επιλέξτε Ακύρωση για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
Ακύρωση
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
)
}
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 && (
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"
>
+ Βάρδια
)}
{isOpen ? (
handleCloseDay(false)}
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
>
Κλείσιμο Ημέρας
) : (
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 ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
)}
{/* 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)}
)}
handleEndShift(s.id, s.waiter_name)}
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
title="Τέλος βάρδιας"
>
⏹
))}
)}
)}
{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 (
)
}
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 (
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 */}
{/* Header: name + status pill */}
{/* Flags row */}
{hasPendingPrint && (
⏳ Εκκρεμής εκτύπωση
)}
{/* Stats row */}
Total
{amount != null ? formatEuro(amount) : — — }
Time
= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : — — }
{/* Waiter row */}
{waiters.length === 0 ? (
Unassigned
) : showMulti ? (
<>
{waiters.slice(0, 3).map((w, i) => (
))}
Multiple ({waiters.length})
>
) : (
waiters.map((w, i) => (
{w.shortName}
))
)}
)
}
// ─── 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 => (
setFilter(f)}
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
>
{FILTER_LABELS[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 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
{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(', ')}
navigate(`/orders/${order.id}`)}
>
Λεπτομέρειες
retrySingleOrder(order.id)}
disabled={retryingId === order.id}
>
{retryingId === order.id ? '…' : 'Εκτύπωση'}
)
})}
)}
)
}