Files
xenia-pos-local/manager_dashboard/src/pages/DashboardPage.jsx
bonamin 5de89a722c feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:54 +03:00

1600 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useContext } 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'
import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
import { LicenseContext } from '../layouts/AppLayout'
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function fmtDuration(isoStart) {
if (!isoStart) return '—'
const mins = Math.floor((Date.now() - new Date(isoStart).getTime()) / 60000)
if (mins < 60) return `${mins}λ`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
}
function fmtEuro(n) {
return '€' + parseFloat(n || 0).toFixed(2)
}
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 Avatar({ name, avatarUrl, size = 36 }) {
if (avatarUrl) {
return (
<img src={avatarUrl} alt={name}
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
)
}
const parts = (name || '?').trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(name || '?'),
color: 'white', fontSize: size * 0.38, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>{initials}</div>
)
}
// ─── Table color tokens ────────────────────────────────────────────────────────
const TABLE_COLORS = {
open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' },
partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' },
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
}
// ─── Business Day / Shift modals ───────────────────────────────────────────────
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 }) {
if (!details.partially_paid) {
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>
)
}
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>
)
}
// ─── Order quick-view modal ────────────────────────────────────────────────────
function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
const qc = useQueryClient()
const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId],
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
enabled: !!orderId,
})
const { data: waiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 60_000,
})
const [confirmAction, setConfirmAction] = useState(null)
const [printerId, setPrinterId] = useState('')
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['order', orderId] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
}
const payItems = useMutation({
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα πληρωμής'),
})
const cancelItem = useMutation({
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα ακύρωσης'),
})
const cancelOrder = useMutation({
mutationFn: () => client.delete(`/api/orders/${orderId}`),
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
})
const closeOrder = useMutation({
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); invalidate(); onClose() },
onError: () => toast.error('Σφάλμα κλεισίματος'),
})
const printOrder = useMutation({
mutationFn: (pid) => client.post(`/api/orders/${orderId}/print`, { printer_id: pid }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
function handleConfirm() {
if (!confirmAction) return
if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
if (confirmAction.type === 'closeOrder') closeOrder.mutate()
setConfirmAction(null)
}
const total = order ? orderTotal(order.items) : 0
const activeItems = order ? order.items.filter(i => i.status === 'active') : []
const isOpen = order ? ['open', 'partially_paid'].includes(order.status) : 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 style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
overflow: 'hidden',
}}>
{/* Modal header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 22px', borderBottom: '1px solid #edeff1', flexShrink: 0,
}}>
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: '#111315' }}>
Τραπέζι {tableName}
</div>
{order && (
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>
Παραγγελία #{order.id} · από {fmtTime(order.opened_at)} · {fmtDuration(order.opened_at)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{order && (
<span style={{
fontSize: 18, fontWeight: 700,
color: '#111315',
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
}}>{fmtEuro(total)}</span>
)}
{order && (
<Badge status={order.status} />
)}
<button
onClick={onClose}
style={{
width: 32, height: 32, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', cursor: 'pointer', fontSize: 16, color: '#5a6169',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
</div>
</div>
{/* Scrollable body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 0 8px' }}>
{isLoading && (
<div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση</div>
)}
{order && (
<>
{/* Waiters row */}
{order.waiters.length > 0 && (
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f4f4f2', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Προσωπικό</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{order.waiters.map(w => (
<span key={w.waiter_id} style={{
padding: '3px 10px', borderRadius: 999,
background: '#f0f4ff', color: '#3758c9',
fontSize: 12, fontWeight: 600,
}}>{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
))}
</div>
</div>
)}
{/* Items list */}
<div>
{order.items.length === 0 && (
<div style={{ padding: '24px', textAlign: 'center', color: '#8a9099', fontSize: 13 }}>Κανένα αντικείμενο.</div>
)}
{order.items.map(item => (
<div key={item.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 22px',
borderBottom: '1px solid #f9f9f8',
opacity: item.status === 'cancelled' ? 0.4 : 1,
textDecoration: item.status === 'cancelled' ? 'line-through' : 'none',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>
{item.product?.name ?? `#${item.product_id}`}
<span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>×{item.quantity}</span>
</div>
{item.notes && <div style={{ fontSize: 11, color: '#8a9099', marginTop: 1 }}>{item.notes}</div>}
{item.paid_by && (
<div style={{ fontSize: 11, color: '#2f9e5e', marginTop: 1, fontWeight: 600 }}>
Πληρώθηκε
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{
fontSize: 14, fontWeight: 700, color: '#111315',
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
minWidth: 52, textAlign: 'right',
}}>{fmtEuro(item.unit_price * item.quantity)}</span>
{isOpen && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #d7ecdc', background: '#eef7f0',
color: '#1f7042', fontSize: 11, fontWeight: 700, cursor: 'pointer',
}}
>Πληρωμή</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', fontSize: 11, fontWeight: 700, cursor: 'pointer',
}}
></button>
</>
)}
</div>
</div>
))}
</div>
</>
)}
</div>
{/* Footer actions */}
{order && (
<div style={{
padding: '14px 22px', borderTop: '1px solid #edeff1',
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
background: '#fafafa', alignItems: 'center',
}}>
{isOpen && activeItems.length > 0 && (
<button
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
style={{
height: 36, padding: '0 16px', borderRadius: 8,
background: '#3758c9', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>Πληρωμή όλων</button>
)}
{isOpen && (
<>
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>Κλείσιμο</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>Ακύρωση</button>
</>
)}
{printers.length > 0 && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
<select
value={printerId}
onChange={e => setPrinterId(e.target.value)}
style={{
height: 36, paddingLeft: 10, paddingRight: 28, borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 12, cursor: 'pointer',
}}
>
<option value="">Εκτυπωτής</option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
disabled={!printerId}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600,
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
}}
>🖨 Εκτύπωση</button>
</div>
)}
<button
onClick={onOpenFull}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer',
marginLeft: printers.length > 0 ? 0 : 'auto',
}}
>Πλήρης εικόνα </button>
</div>
)}
</div>
{confirmAction && (
<ConfirmModal
title={
confirmAction.type === 'cancelItem' ? 'Ακύρωση αντικειμένου;' :
confirmAction.type === 'cancelOrder' ? 'Ακύρωση παραγγελίας;' :
'Κλείσιμο παραγγελίας;'
}
message={confirmAction.type === 'cancelOrder' ? 'Η παραγγελία θα ακυρωθεί οριστικά.' : undefined}
confirmLabel={confirmAction.type === 'closeOrder' ? 'Κλείσιμο' : 'Ακύρωση'}
confirmVariant={confirmAction.type === 'closeOrder' ? 'primary' : 'danger'}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
)
}
// ─── KPI Card ─────────────────────────────────────────────────────────────────
function KpiCard({ label, value, sub, accent = '#3758c9', pct, private: isPrivate }) {
return (
<div style={{
background: 'white',
border: '1px solid #edeff1',
borderRadius: 16,
padding: '20px 22px',
display: 'flex', flexDirection: 'column', gap: 6,
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
flex: 1,
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div>
<div style={{
fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
filter: isPrivate ? 'blur(8px)' : 'none',
userSelect: isPrivate ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{value}</div>
{sub && <div style={{
fontSize: 12, color: '#5a6169',
filter: isPrivate ? 'blur(6px)' : 'none',
userSelect: isPrivate ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{sub}</div>}
{pct != null && (
<div style={{ marginTop: 4, height: 6, borderRadius: 3, background: '#edeff1', overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: accent, borderRadius: 3, transition: 'width 300ms ease' }} />
</div>
)}
</div>
)
}
// ─── Mini table chip ──────────────────────────────────────────────────────────
function TableChip({ name, status, amount, onClick }) {
const c = TABLE_COLORS[status] || TABLE_COLORS.free
return (
<button
onClick={onClick}
title={amount != null ? fmtEuro(amount) : name}
style={{
aspectRatio: '1', minHeight: 52,
borderRadius: 10,
background: c.bg,
border: '1px solid ' + c.border,
color: c.text,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
cursor: onClick ? 'pointer' : 'default',
transition: 'transform 100ms ease, box-shadow 100ms ease',
}}
onMouseEnter={e => { if (onClick) { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)' } }}
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '' }}
>
{name}
</button>
)
}
// ─── Shifts card ──────────────────────────────────────────────────────────────
// ─── End shift confirmation modal ─────────────────────────────────────────────
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
const [notes, setNotes] = useState('')
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 gap-3">
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 18 }}></span>
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας;</div>
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
</div>
</div>
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
rows={2}
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
onFocus={e => e.target.style.borderColor = '#6366f1'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
<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(notes)} 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>
)
}
// ─── Compose for single waiter (quick message from shift row) ─────────────────
function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
const [body, setBody] = useState('')
const sendMut = useMutation({
mutationFn: (payload) => client.post('/api/messages/send', payload),
onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
onError: () => toast.error('Σφάλμα αποστολής'),
})
function send() {
if (!body.trim()) return
sendMut.mutate({ body: body.trim(), target_waiter_ids: [waiter.id], table_ids: [] })
}
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-5 space-y-4">
<div className="flex items-center justify-between">
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>💬 Μήνυμα σε {waiter.name}</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#8a9099' }}></button>
</div>
{templates.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{templates.map(t => (
<button key={t.id} onClick={() => setBody(t.body)} style={{
height: 26, padding: '0 10px', borderRadius: 6,
border: `1px solid ${body === t.body ? '#3758c9' : '#dfe2e6'}`,
background: body === t.body ? '#eff3ff' : '#f9fafb',
color: body === t.body ? '#3758c9' : '#374151',
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
}}>{t.body}</button>
))}
</div>
)}
<textarea
placeholder="Μήνυμα…"
value={body}
onChange={e => setBody(e.target.value)}
rows={3}
style={{ width: '100%', borderRadius: 10, border: '1.5px solid #dfe2e6', padding: '10px 14px', fontSize: 14, fontFamily: 'inherit', resize: 'vertical', outline: 'none', boxSizing: 'border-box', color: '#111315' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ height: 36, padding: '0 16px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
<button onClick={send} disabled={!body.trim() || sendMut.isPending}
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: !body.trim() ? 0.5 : 1 }}>
{sendMut.isPending ? 'Αποστολή…' : '📤 Αποστολή'}
</button>
</div>
</div>
</div>
)
}
function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter, privacyMode }) {
return (
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Βάρδιες σε εξέλιξη</div>
{isOpen && waitersWithoutShift.length > 0 && (
<button
onClick={onStartShift}
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>+ Έναρξη</button>
)}
</div>
<div style={{ padding: '12px 20px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{activeShifts.length === 0 ? (
<p style={{ fontSize: 13, color: '#8a9099', padding: '8px 0' }}>Κανένας σερβιτόρος σε βάρδια</p>
) : (
activeShifts.map(s => {
const activeBreak = Array.isArray(s.breaks) ? s.breaks.find(b => !b.ended_at) : null
return (
<div key={s.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 12,
border: activeBreak ? '1px solid #fed7aa' : '1px solid #edeff1',
background: activeBreak ? '#fff7ed' : '#fafafa',
transition: 'background 200ms, border-color 200ms',
}}>
<Avatar name={s.waiter_name} avatarUrl={s.avatar_url ?? null} size={36} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{s.waiter_name}</span>
{activeBreak && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 999,
background: '#fed7aa', color: '#9a3412',
fontSize: 11, fontWeight: 700,
}}>
Διάλειμμα · {fmtDuration(activeBreak.started_at)}
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1 }}>
από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)}
{s.total_collected > 0 && (
<span style={{
color: '#2f9e5e', fontWeight: 600, marginLeft: 6,
filter: privacyMode ? 'blur(6px)' : 'none',
userSelect: privacyMode ? 'none' : 'auto',
transition: 'filter 200ms ease',
}}>{fmtEuro(s.total_collected)}</span>
)}
</div>
</div>
<button
onClick={() => onMessageWaiter(s)}
title="Αποστολή μηνύματος"
style={{
height: 28, width: 28, borderRadius: 8, flexShrink: 0,
border: '1px solid #dfe2e6', background: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, cursor: 'pointer',
}}
>💬</button>
<button
onClick={() => onEndShift(s)}
style={{
height: 28, padding: '0 10px', borderRadius: 8,
border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
}}
>Τέλος</button>
</div>
)
})
)}
</div>
</div>
)
}
// ─── Revenue bar chart ─────────────────────────────────────────────────────────
function RevenueChart({ orders }) {
const now = new Date()
const currentHour = now.getHours()
const buckets = {}
for (let h = 10; h <= 23; h++) buckets[h] = 0
orders.forEach(o => {
const h = new Date(o.opened_at).getHours()
if (h >= 10 && h <= 23) buckets[h] = (buckets[h] || 0) + orderTotal(o.items)
})
const hours = Object.keys(buckets).map(Number)
const max = Math.max(...Object.values(buckets), 1)
return (
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, padding: '16px 20px',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', marginBottom: 16 }}>Έσοδα ανά ώρα</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 90 }}>
{hours.map(h => {
const val = buckets[h]
const heightPct = (val / max) * 100
const isCurrent = h === currentHour
const isFuture = h > currentHour
return (
<div key={h} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, height: '100%' }}>
<div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end' }}>
<div title={fmtEuro(val)} style={{
width: '100%', height: val > 0 ? `${heightPct}%` : '3px',
minHeight: val > 0 ? 4 : 3, borderRadius: 4,
background: isCurrent ? '#3758c9' : isFuture ? '#edeff1' : '#c2cff0',
transition: 'height 300ms ease',
}} />
</div>
<div style={{
fontSize: 9, fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
color: isCurrent ? '#3758c9' : '#8a9099', fontWeight: isCurrent ? 700 : 500,
}}>{h}</div>
</div>
)
})}
</div>
</div>
)
}
// ─── Reservations stub ────────────────────────────────────────────────────────
function ReservationsCard() {
return (
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
<button style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>+ Νέα</button>
</div>
<div style={{
padding: '32px 20px', textAlign: 'center',
color: '#b8bdc4', fontSize: 13,
}}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
Σύντομα σύστημα κρατήσεων υπό ανάπτυξη
</div>
</div>
)
}
// ─── Messages card ────────────────────────────────────────────────────────────
function ComposeModal({ waiters, tables, templates, onClose, onSent }) {
const [selectedWaiters, setSelectedWaiters] = useState([]) // [] = all
const [selectedTables, setSelectedTables] = useState([])
const [body, setBody] = useState('')
const sendMut = useMutation({
mutationFn: (payload) => client.post('/api/messages/send', payload),
onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
onError: () => toast.error('Σφάλμα αποστολής'),
})
function toggleWaiter(id) {
setSelectedWaiters(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
function toggleTable(id) {
setSelectedTables(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
function send() {
if (!body.trim()) return
sendMut.mutate({
body: body.trim(),
target_waiter_ids: selectedWaiters, // [] = broadcast all
table_ids: selectedTables,
})
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} onClick={onClose}>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 520,
padding: 24, boxShadow: '0 24px 64px rgba(0,0,0,0.22)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Νέο Μήνυμα</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#8a9099' }}></button>
</div>
{/* Recipients */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
Παραλήπτες {selectedWaiters.length === 0 && <span style={{ color: '#3758c9', fontWeight: 700 }}>(Όλοι)</span>}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{waiters.map(w => {
const name = w.nickname || w.full_name || w.username
const sel = selectedWaiters.includes(w.id)
return (
<button key={w.id} onClick={() => toggleWaiter(w.id)} style={{
height: 30, padding: '0 12px', borderRadius: 999,
border: '1.5px solid ' + (sel ? '#3758c9' : '#dfe2e6'),
background: sel ? '#eff3ff' : 'white',
color: sel ? '#3758c9' : '#374151',
fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
}}>{name}</button>
)
})}
</div>
</div>
{/* Tables (optional) */}
{tables.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
Σχετικά Τραπέζια (προαιρετικό)
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 80, overflowY: 'auto' }}>
{tables.map(t => {
const name = t.label || `T${t.number}`
const sel = selectedTables.includes(t.id)
return (
<button key={t.id} onClick={() => toggleTable(t.id)} style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1.5px solid ' + (sel ? '#7a44c9' : '#dfe2e6'),
background: sel ? '#f5eeff' : 'white',
color: sel ? '#7a44c9' : '#374151',
fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
}}>{name}</button>
)
})}
</div>
</div>
)}
{/* Quick templates */}
{templates.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>Γρήγορα Μηνύματα</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{templates.map(t => (
<button key={t.id} onClick={() => setBody(t.body)} style={{
height: 28, padding: '0 12px', borderRadius: 6,
border: '1px solid ' + (body === t.body ? '#3758c9' : '#dfe2e6'),
background: body === t.body ? '#eff3ff' : '#f9fafb',
color: body === t.body ? '#3758c9' : '#374151',
fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
}}>{t.body}</button>
))}
</div>
</div>
)}
{/* Custom body */}
<textarea
placeholder="Ή γράψτε το δικό σας μήνυμα…"
value={body}
onChange={e => setBody(e.target.value)}
rows={3}
style={{
width: '100%', borderRadius: 10, border: '1.5px solid #dfe2e6',
padding: '10px 14px', fontSize: 14, fontFamily: 'inherit',
resize: 'vertical', outline: 'none', boxSizing: 'border-box',
color: '#111315',
}}
/>
<div style={{ display: 'flex', gap: 10, marginTop: 16, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ height: 38, padding: '0 18px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
<button
onClick={send}
disabled={!body.trim() || sendMut.isPending}
style={{ height: 38, padding: '0 20px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: !body.trim() ? 0.5 : 1 }}
>{sendMut.isPending ? 'Αποστολή…' : '📤 Αποστολή'}</button>
</div>
</div>
</div>
)
}
function MessagesCard({ waiters, tables }) {
const qc = useQueryClient()
const [composing, setComposing] = useState(false)
const { data: messages = [] } = useQuery({
queryKey: ['messages-all'],
queryFn: () => client.get('/api/messages/all?limit=20').then(r => r.data),
refetchInterval: 10_000,
})
const { data: templates = [] } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
staleTime: 60_000,
})
function parseJsonField(val) {
if (Array.isArray(val)) return val
try { return JSON.parse(val || '[]') } catch { return [] }
}
function fmtTargets(msg) {
const ids = parseJsonField(msg.target_waiter_ids)
if (ids.length === 0) return 'Όλοι'
return ids.map(id => {
const w = waiters.find(w => w.id === id)
return w ? (w.nickname || w.full_name || w.username) : `#${id}`
}).join(', ')
}
return (
<>
{composing && (
<ComposeModal
waiters={waiters}
tables={tables}
templates={templates}
onClose={() => setComposing(false)}
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
/>
)}
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Μηνύματα</div>
<button
onClick={() => setComposing(true)}
style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>+ Νέο</button>
</div>
{messages.length === 0 ? (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>💬</div>
Δεν υπάρχουν μηνύματα ακόμα
</div>
) : (
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{messages.map(msg => {
const ackedIds = parseJsonField(msg.acked_by).length
const totalTargets = parseJsonField(msg.target_waiter_ids).length || waiters.length
return (
<div key={msg.id} style={{
padding: '10px 20px', borderBottom: '1px solid #f4f4f2',
display: 'flex', gap: 12, alignItems: 'flex-start',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', marginBottom: 2 }}>{msg.body}</div>
<div style={{ fontSize: 11, color: '#8a9099' }}>
Προς: {fmtTargets(msg)} · {new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div style={{
fontSize: 11, fontWeight: 700,
color: ackedIds >= totalTargets ? '#16a34a' : '#f59e0b',
background: ackedIds >= totalTargets ? '#f0fdf4' : '#fffbeb',
border: `1px solid ${ackedIds >= totalTargets ? '#bbf7d0' : '#fde68a'}`,
borderRadius: 999, padding: '2px 8px', whiteSpace: 'nowrap', flexShrink: 0,
}}>
{ackedIds}/{totalTargets}
</div>
</div>
)
})}
</div>
)}
</div>
</>
)
}
// ─── Pending prints panel ──────────────────────────────────────────────────────
function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onViewOrder, retryingId }) {
if (pendingPrintOrders.length === 0) return null
return (
<div style={{
background: 'white', borderRadius: 16, overflow: 'hidden',
border: '1px solid #fed7aa', boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 20px', background: '#fff7ed', borderBottom: '1px solid #fed7aa',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 18 }}></span>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#9a3412' }}>Εκκρεμείς Εκτυπώσεις</div>
<div style={{ fontSize: 12, color: '#c2410c', marginTop: 1 }}>
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί
</div>
</div>
</div>
<button
onClick={onRetryAll} disabled={retryingId !== null}
style={{
height: 32, padding: '0 14px', borderRadius: 8,
background: '#c2410c', border: 'none', color: 'white',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}</button>
</div>
{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} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 20px', borderBottom: '1px solid #fff7ed',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: 12, background: '#fff7ed', color: '#c2410c',
border: '1px solid #fed7aa', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
}}>{tableName}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
</div>
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button onClick={() => onViewOrder(order.id)} className="btn btn-secondary text-xs">Λεπτομέρειες</button>
<button
onClick={() => onRetrySingle(order.id)} disabled={retryingId === order.id}
style={{
height: 28, padding: '0 10px', borderRadius: 6,
background: '#c2410c', border: 'none', color: 'white',
fontSize: 11, fontWeight: 600, cursor: 'pointer',
}}
>{retryingId === order.id ? '…' : 'Εκτύπωση'}</button>
</div>
</div>
)
})}
</div>
)
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false)
const [retryingId, setRetryingId] = useState(null)
const [quickView, setQuickView] = useState(null)
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
// End shift flow: confirm modal → summary modal
const [endShiftTarget, setEndShiftTarget] = useState(null) // shift object to confirm
const [endShiftBusy, setEndShiftBusy] = useState(false)
const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id
// Quick message to single waiter
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
const navigate = useNavigate()
const qc = useQueryClient()
const license = useContext(LicenseContext)
const { data: businessDay } = useQuery({
queryKey: ['business-day'],
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
refetchInterval: 10_000,
})
const { data: activeShifts = [] } = useQuery({
queryKey: ['active-shifts'],
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
refetchInterval: 10_000,
})
const { data: allWaiters = [] } = useQuery({
queryKey: ['waiters'],
queryFn: () => client.get('/api/waiters/').then(r => r.data),
staleTime: 60_000,
})
const { data: quickTemplates = [] } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
staleTime: 60_000,
})
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 openDayMut = useMutation({
mutationFn: () => client.post('/api/business-day/open', {}),
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
onError: (e) => {
const detail = e.response?.data?.detail
if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') {
setLicenseBlock({ code: detail.code, message: detail.message })
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα')
}
},
})
function handleOpenDay() {
if (license?.isBlocked) {
setLicenseBlock({
code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED',
message: license.lock_reason === 'admin'
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
: 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.',
})
return
}
openDayMut.mutate()
}
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'] })
qc.invalidateQueries({ queryKey: ['tables'] })
qc.invalidateQueries({ queryKey: ['messages-all'] })
} 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 handleEndShiftConfirm(notes) {
if (!endShiftTarget) return
setEndShiftBusy(true)
try {
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
setEndShiftTarget(null)
setShiftSummaryId(summaryTarget)
} catch (e) {
toast.error(e.response?.data?.detail || 'Σφάλμα')
} finally {
setEndShiftBusy(false)
}
}
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'] })
}
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}`)
}
qc.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 isOpen = !!businessDay
const waitersWithoutShift = allWaiters.filter(
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
)
// Build table cards
const tableCards = tables.map(table => {
const order = orders.find(o =>
o.table_id === table.id && ['open', 'partially_paid', '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)
// KPIs — revenue scoped to orders that opened after business day start
const businessDayStart = businessDay?.opened_at ? new Date(businessDay.opened_at) : null
const dayOrders = businessDayStart
? orders.filter(o => new Date(o.opened_at) >= businessDayStart)
: orders
const totalRevenue = dayOrders.reduce((s, o) => s + orderTotal(o.items), 0)
const openCount = tableCards.filter(c => c.tableStatus === 'open').length
const partialCount = tableCards.filter(c => c.tableStatus === 'partially_paid').length
const occupiedCount = openCount + partialCount
const freeCount = tableCards.filter(c => c.tableStatus === 'free').length
// Avg ticket: revenue divided by number of orders with at least one paid item
const paidOrders = dayOrders.filter(o => o.items.some(i => i.paid_by))
const avgTicket = paidOrders.length > 0 ? totalRevenue / paidOrders.length : 0
// Total collected by all active shifts
const totalCollected = activeShifts.reduce((s, sh) => s + (sh.total_collected || 0), 0)
const todayStr = new Date().toLocaleDateString('el-GR', { weekday: 'long', day: 'numeric', month: 'long' })
if (tablesLoading || ordersLoading) {
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
}
return (
<div className="overflow-y-auto h-full p-6" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* ── Header bar ─────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 999,
background: isOpen ? '#eef7f0' : '#f4f4f2',
color: isOpen ? '#1f7042' : '#5a6169',
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5,
}}>
<span style={{
width: 6, height: 6, borderRadius: 3,
background: isOpen ? '#2f9e5e' : '#8a9099',
boxShadow: isOpen ? '0 0 0 2px #bbf7d0' : 'none',
}} />
{isOpen ? 'Ημέρα Ανοιχτή' : 'Ημέρα Κλειστή'}
</span>
</div>
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>
{todayStr}
{isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={() => setPrivacyMode(p => { const next = !p; localStorage.setItem('privacyMode', next); return next })}
title={privacyMode ? 'Εμφάνιση ποσών' : 'Απόκρυψη ποσών'}
style={{
height: 38, width: 38, borderRadius: 10, flexShrink: 0,
border: '1px solid #dfe2e6',
background: privacyMode ? '#1e293b' : 'white',
color: privacyMode ? '#94a3b8' : '#5a6169',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, transition: 'background 200ms, color 200ms',
}}
>
{privacyMode ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
{isOpen ? (
<button onClick={() => handleCloseDay(false)} style={{
height: 38, padding: '0 18px', borderRadius: 10,
background: '#dc2626', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Κλείσιμο Ημέρας</button>
) : (
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
height: 38, padding: '0 18px', borderRadius: 10,
background: '#16a34a', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}</button>
)}
</div>
</div>
{/* ── KPI strip — full-width flex row ────────────────────────────────── */}
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<KpiCard
label="Έσοδα ημέρας"
value={fmtEuro(totalRevenue)}
sub={`${dayOrders.length} παραγγελί${dayOrders.length !== 1 ? 'ες' : 'α'} · μ.ο. ${fmtEuro(avgTicket)}`}
accent="#3758c9"
private={privacyMode}
/>
<KpiCard
label="Τραπέζια σε χρήση"
value={`${occupiedCount} / ${tables.length}`}
sub={`${freeCount} ελεύθερα · ${partialCount} μερική πληρ.`}
accent="#7a44c9"
pct={tables.length > 0 ? (occupiedCount / tables.length) * 100 : 0}
/>
<KpiCard
label="Βάρδιες ενεργές"
value={String(activeShifts.length)}
sub={activeShifts.length > 0 ? activeShifts.map(s => s.waiter_name.split(' ')[0]).join(', ') : 'Κανένας σε βάρδια'}
accent="#2f9e5e"
/>
<KpiCard
label="Είσπραξη βαρδιών"
value={fmtEuro(totalCollected)}
sub={`Σύνολο από ${activeShifts.length} βάρδι${activeShifts.length === 1 ? 'α' : 'ες'}`}
accent="#0d7a8a"
private={privacyMode}
/>
{pendingPrintOrders.length > 0 && (
<KpiCard
label="Εκκρεμείς εκτυπώσεις"
value={String(pendingPrintOrders.length)}
sub="Απαιτείται προσοχή"
accent="#d94b26"
/>
)}
</div>
{/* ── Main 2-column layout: 65 / 35 ──────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '65fr 35fr', gap: 20, alignItems: 'start' }}>
{/* ── Left column — Shifts + Tables + Revenue + Pending prints ───────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Shifts card — FIRST */}
<ShiftsCard
activeShifts={activeShifts}
waitersWithoutShift={waitersWithoutShift}
isOpen={isOpen}
onStartShift={() => setShowStartShift(true)}
onEndShift={(shift) => setEndShiftTarget(shift)}
onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })}
privacyMode={privacyMode}
/>
{/* Tables overview — SECOND */}
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Τραπέζια τώρα</div>
<button
onClick={() => navigate('/tables')}
style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>Πλήρης εικόνα </button>
</div>
<div style={{ padding: '16px 20px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))',
gap: 8, marginBottom: 14,
}}>
{tableCards.map(({ table, tableStatus, order }) => (
<TableChip
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={order ? orderTotal(order.items) : null}
onClick={order ? () => setQuickView({ orderId: order.id, tableName: table.label || `T${table.number}` }) : undefined}
/>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(TABLE_COLORS).map(([key, c]) => (
<div key={key} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 999, background: '#edeff1',
fontSize: 12, fontWeight: 600, color: '#2b2f33',
}}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: c.dot }} />
{c.label}
<span style={{ fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace", color: '#111315' }}>
{tableCards.filter(tc => tc.tableStatus === key).length}
</span>
</div>
))}
</div>
</div>
</div>
{/* Revenue chart */}
<RevenueChart orders={dayOrders} />
{/* Pending prints */}
<PendingPrintsPanel
pendingPrintOrders={pendingPrintOrders}
onRetryAll={retryAllOrders}
onRetrySingle={retrySingleOrder}
onViewOrder={id => setQuickView({ orderId: id, tableName: tables.find(t => tableCards.find(tc => tc.order?.id === id)?.table.id === t.id)?.label || '—' })}
retryingId={retryingId}
/>
</div>
{/* ── Right column — Reservations + Messages ───────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<ReservationsCard />
<MessagesCard waiters={allWaiters} tables={tables} />
</div>
</div>
{/* ── Modals ─────────────────────────────────────────────────────────── */}
{showStartShift && (
<StartShiftModal
waiters={waitersWithoutShift}
onClose={() => setShowStartShift(false)}
onStart={handleStartShift}
/>
)}
{endShiftTarget && (
<EndShiftConfirmModal
shift={endShiftTarget}
onClose={() => setEndShiftTarget(null)}
onConfirm={handleEndShiftConfirm}
busy={endShiftBusy}
/>
)}
{shiftSummaryId && (
<ShiftDetailModal
shiftId={shiftSummaryId.id}
shiftWaiterId={shiftSummaryId.waiter_id}
onClose={() => setShiftSummaryId(null)}
/>
)}
{messageWaiter && (
<QuickMessageModal
waiter={messageWaiter}
tables={tables}
templates={quickTemplates}
onClose={() => setMessageWaiter(null)}
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => setCloseDetails(null)}
onConfirm={() => handleCloseDay(true)}
busy={forceClosing}
/>
)}
{quickView && (
<OrderQuickModal
orderId={quickView.orderId}
tableName={quickView.tableName}
onClose={() => setQuickView(null)}
onOpenFull={() => { navigate(`/orders/${quickView.orderId}`); setQuickView(null) }}
/>
)}
{licenseBlock && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
<div className="flex justify-center">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
}`}>
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
</div>
</div>
<h2 className="text-[15px] font-bold text-slate-900">
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
</h2>
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
<button
onClick={() => setLicenseBlock(null)}
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
>
Κλείσιμο
</button>
</div>
</div>
)}
</div>
)
}