feat: Feature 2 — waiter order cancellation + report updates

Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA

Manager dashboard:
- Global cancel setting toggle in Settings > Operation > Shift Settings
- Per-waiter can_cancel_orders checkbox in staff modal
- Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage
- ShiftsOverview: Ακυρώσεις column per shift
- Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes,
  grouped/stacked switch, right X-axis for hours, full waiter name on hover
- OrderHistory: cancelled items count column per order
- WorkDaySummary drill-down: cancelled items column in orders tab

Waiter PWA:
- Replace 3 pills with CLEAR | ALL | ACTIONS
- ACTIONS opens ItemActionModal for selected items
- ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER
- ActionsSheet: Cancel Παραγγελίας option (greyed if no permission)
- CancelConfirmModal: requires confirmation before cancelling
- TableListPage: cancel order from long-press quick modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:47:02 +03:00
parent b5b647422a
commit 8a5a6f8be9
16 changed files with 855 additions and 108 deletions

View File

@@ -83,69 +83,104 @@ function SplitModal({ item, onConfirm, onClose }) {
// ─── Item action modal (long-press) ──────────────────────────────────────────
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
function ItemActionModal({ target, onOrderAgain, onSplit, onMoveToTable, onCancel, canCancel, onClose }) {
const { items, singleStacked, multiSelect } = target
const label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα`
: items[0]?.product?.name || `#${items[0]?.product_id}`
const actionRows = [
{
key: 'order_again',
color: '#22c55e',
iconBg: 'rgba(34,197,94,0.15)',
label: 'Παραγγελία ξανά',
sub: 'Προσθήκη στο νέο καλάθι',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 4v6h6M23 20v-6h-6" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onOrderAgain,
enabled: true,
},
{
key: 'move',
color: '#f97316',
iconBg: 'rgba(249,115,22,0.15)',
label: 'Μεταφορά σε άλλο τραπέζι',
sub: 'Μεταφορά επιλεγμένων αντικειμένων',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14M12 5l7 7-7 7" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onMoveToTable,
enabled: true,
},
...(singleStacked && !multiSelect ? [{
key: 'split',
color: '#60a5fa',
iconBg: 'rgba(96,165,250,0.15)',
label: 'Διαχωρισμός',
sub: 'Χώρισμα σε δύο γραμμές',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
onClick: onSplit,
enabled: true,
}] : []),
{
key: 'cancel',
color: canCancel ? '#ef4444' : '#6b7280',
iconBg: canCancel ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)',
label: 'Ακύρωση παραγγελίας',
sub: canCancel ? 'Ακύρωση επιλεγμένων αντικειμένων' : 'Δεν επιτρέπεται η ακύρωση',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2" strokeLinecap="round"/>
</svg>
),
onClick: canCancel ? onCancel : null,
enabled: canCancel,
},
]
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
<div className="modal-handle" />
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
<button
onClick={onOrderAgain}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(245,158,11,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
{singleStacked && !multiSelect && (
{actionRows.map((a, i) => (
<button
onClick={onSplit}
key={a.key}
onClick={a.onClick ? a.onClick : undefined}
disabled={!a.enabled}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
cursor: 'pointer', textAlign: 'left',
borderBottom: i < actionRows.length - 1 ? '1px solid var(--border)' : 'none',
cursor: a.enabled ? 'pointer' : 'not-allowed',
opacity: a.enabled ? 1 : 0.45, textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(96,165,250,0.15)',
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
}}>{a.icon}</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
<div style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{a.sub}</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
{a.enabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>}
</button>
)}
))}
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
Άκυρο
@@ -157,14 +192,25 @@ function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
// ─── Actions top sheet ────────────────────────────────────────────────────────
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
function CancelIcon({ width = 20, height = 20 }) {
return (
<svg width={width} height={height} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
)
}
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis, onCancelOrder, canCancel }) {
const hasOrder = !!order
const actions = [
{ Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: MergeIcon, label: 'Συγχώνευση Τραπεζιού', sub: 'Συγχώνευση με άλλο τραπέζι', onClick: hasOrder ? onMerge : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
{ Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: MergeIcon, label: 'Συγχώνευση Τραπεζιού', sub: 'Συγχώνευση με άλλο τραπέζι', onClick: hasOrder ? onMerge : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
{ Icon: CancelIcon, label: 'Ακύρωση Παραγγελίας', sub: canCancel ? 'Ακύρωση ολόκληρης παραγγελίας' : 'Δεν επιτρέπεται η ακύρωση',
onClick: (hasOrder && canCancel) ? onCancelOrder : null, color: (hasOrder && canCancel) ? '#ef4444' : '#6b7280', iconBg: (hasOrder && canCancel) ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)' },
]
return (
@@ -437,6 +483,32 @@ function PrintSynopsisPicker({ orderId, onClose }) {
// ─── Pay confirm modal ────────────────────────────────────────────────────────
function CancelConfirmModal({ itemCount, onConfirm, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">Ακύρωση παραγγελίας;</h2>
<p style={{ color: '#94a3b8', textAlign: 'center', marginBottom: 24, fontSize: 14 }}>
{itemCount === null
? 'Ολόκληρη η παραγγελία θα ακυρωθεί οριστικά.'
: `${itemCount} αντικείμενο${itemCount !== 1 ? 'α' : ''} θα ακυρωθ${itemCount !== 1 ? 'ούν' : 'εί'} οριστικά.`}
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Άκυρο</button>
<button
onClick={onConfirm}
style={{
flex: 1, height: 48, borderRadius: 12, border: 'none',
background: '#dc2626', color: 'white', fontSize: 15, fontWeight: 700, cursor: 'pointer',
}}
>Ακύρωση </button>
</div>
</div>
</div>
)
}
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
const payTotal = activeItems
.filter(i => payIds.includes(i.id))
@@ -504,6 +576,8 @@ export default function TableDetailPage() {
const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null)
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
const [canCancel, setCanCancel] = useState(false)
const [cancelConfirm, setCancelConfirm] = useState(null) // null | { mode: 'items', ids: [...] } | { mode: 'order' }
const scrollRef = useRef(null)
@@ -533,6 +607,12 @@ export default function TableDetailPage() {
useEffect(() => { load() }, [tableId])
useEffect(() => {
client.get('/api/orders/cancel-permissions')
.then(r => setCanCancel(!!r.data.can_cancel))
.catch(() => {})
}, [])
// Handle ?action= param from table list long-press quick actions
useEffect(() => {
const action = searchParams.get('action')
@@ -624,6 +704,28 @@ export default function TableDetailPage() {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
async function executeCancelItems(itemIds) {
setCancelConfirm(null)
try {
await client.post(`/api/orders/${order.id}/cancel-items`, { item_ids: itemIds, print_cancellation: true })
setSelectedIds([])
setItemActionTarget(null)
await load()
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης')
}
}
async function executeCancelOrder() {
setCancelConfirm(null)
try {
await client.delete(`/api/orders/${order.id}?print_cancellation=true`)
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης παραγγελίας')
}
}
function selectAll() {
const allActive = activeItems.map(i => i.id)
const allSelected = allActive.every(id => selectedIds.includes(id))
@@ -892,7 +994,7 @@ export default function TableDetailPage() {
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
padding: '10px 12px 24px',
}}>
{/* Clear selection */}
{/* CLEAR */}
<button
onClick={() => setSelectedIds([])}
style={{
@@ -901,13 +1003,11 @@ export default function TableDetailPage() {
borderRadius: 999,
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
καθ. επιλ.
</button>
>CLEAR</button>
{/* Select all — hidden once everything is already selected */}
{/* ALL */}
{!allActiveSelected && (
<button
onClick={selectAll}
@@ -917,10 +1017,10 @@ export default function TableDetailPage() {
borderRadius: 999,
background: 'rgba(34,197,94,0.18)', color: '#86efac',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
όλα
ALL
<span style={{
background: 'rgba(34,197,94,0.35)', color: '#86efac',
borderRadius: 999, fontSize: 11, fontWeight: 700,
@@ -929,26 +1029,25 @@ export default function TableDetailPage() {
</button>
)}
{/* Transfer items */}
{/* ACTIONS — opens item action modal */}
<button
onClick={() => {
setAllTables([])
setAllOrders([])
loadActionData()
setActionsMode('move_items')
const items = activeItems.filter(i => selectedIds.includes(i.id))
const singleStacked = items.length === 1 && items[0].quantity > 1
setItemActionTarget({ items, singleStacked, multiSelect: items.length > 1 })
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px',
borderRadius: 999,
background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
background: 'rgba(148,163,184,0.18)', color: '#cbd5e1',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
μεταφορά
ACTIONS
<span style={{
background: 'rgba(96,165,250,0.35)', color: '#93c5fd',
background: 'rgba(148,163,184,0.25)', color: '#cbd5e1',
borderRadius: 999, fontSize: 11, fontWeight: 700,
padding: '1px 6px',
}}>{selectedIds.length}</span>
@@ -1020,10 +1119,11 @@ export default function TableDetailPage() {
</div>
)}
{/* Item action modal (long-press) */}
{/* Item action modal (long-press or ACTIONS pill) */}
{itemActionTarget && (
<ItemActionModal
target={itemActionTarget}
canCancel={canCancel}
onOrderAgain={() => {
const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify(
@@ -1042,10 +1142,34 @@ export default function TableDetailPage() {
setSplitItem(itemActionTarget.items[0])
setItemActionTarget(null)
}}
onMoveToTable={() => {
setItemActionTarget(null)
setAllTables([])
setAllOrders([])
loadActionData()
setActionsMode('move_items')
}}
onCancel={() => {
const ids = itemActionTarget.items.map(i => i.id)
setItemActionTarget(null)
setCancelConfirm({ mode: 'items', ids })
}}
onClose={() => setItemActionTarget(null)}
/>
)}
{/* Cancel confirmation */}
{cancelConfirm && (
<CancelConfirmModal
itemCount={cancelConfirm.mode === 'items' ? cancelConfirm.ids.length : null}
onConfirm={() => {
if (cancelConfirm.mode === 'items') executeCancelItems(cancelConfirm.ids)
else executeCancelOrder()
}}
onClose={() => setCancelConfirm(null)}
/>
)}
{/* Split stepper modal */}
{splitItem && (
<SplitModal
@@ -1097,12 +1221,14 @@ export default function TableDetailPage() {
<ActionsSheet
order={order}
tableId={tableId}
canCancel={canCancel}
onClose={() => setShowActions(false)}
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
onMerge={() => { setShowActions(false); setActionsMode('merge') }}
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
onCancelOrder={() => { setShowActions(false); setCancelConfirm({ mode: 'order' }) }}
/>
)}

View File

@@ -79,7 +79,7 @@ const QUICK_ACTIONS = [
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
]
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) {
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction, onCancelOrder, canCancel }) {
const tableName = table.label || `T${table.number}`
const activeItems = order?.items?.filter(i => i.status === 'active') || []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
@@ -147,7 +147,7 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0', background: 'none', border: 'none',
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
borderBottom: '1px solid var(--border)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}>
@@ -159,6 +159,30 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
</button>
)
})}
{/* Cancel order */}
{(() => {
const hasActiveItems = activeItems.length > 0
const disabled = !order || !hasActiveItems
const colour = canCancel && !disabled ? '#ef4444' : 'var(--muted)'
return (
<button
disabled={!canCancel || disabled}
onClick={() => { if (canCancel && !disabled) onCancelOrder() }}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0', background: 'none', border: 'none',
cursor: canCancel && !disabled ? 'pointer' : 'not-allowed',
opacity: canCancel && !disabled ? 1 : 0.35, textAlign: 'left',
}}>
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: 'rgba(239,68,68,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: colour }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</span>
<span style={{ fontSize: 15, fontWeight: 600, color: colour }}>Ακύρωση Παραγγελίας</span>
</button>
)
})()}
</div>
</div>
</div>
@@ -358,6 +382,8 @@ export default function TableListPage() {
const [quickModal, setQuickModal] = useState(null)
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
const [canCancel, setCanCancel] = useState(false)
const [cancelConfirm, setCancelConfirm] = useState(null)
// pull-to-refresh state
const [pulling, setPulling] = useState(false)
@@ -433,6 +459,19 @@ export default function TableListPage() {
useEffect(() => { load() }, [])
useEffect(() => {
client.get('/api/orders/cancel-permissions').then(r => setCanCancel(r.data?.can_cancel === true)).catch(() => {})
}, [])
async function executeCancelOrder(orderId) {
try {
await client.delete(`/api/orders/${orderId}?print_cancellation=true`)
setCancelConfirm(null)
setQuickModal(null)
load()
} catch {}
}
// ── SSE live updates ───────────────────────────────────────────────────────
useEffect(() => {
if (isEmergency) return
@@ -736,12 +775,28 @@ export default function TableListPage() {
table={quickModal.table}
order={quickModal.order}
flags={quickModal.flags}
canCancel={canCancel}
onClose={() => setQuickModal(null)}
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
onCancelOrder={() => setCancelConfirm({ orderId: quickModal.order?.id })}
/>
)}
{cancelConfirm && (
<div className="modal-overlay" onClick={() => setCancelConfirm(null)}>
<div style={{ width: '100%', maxWidth: 400, margin: '0 auto', background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '24px 20px 32px' }} onClick={e => e.stopPropagation()}>
<div className="modal-handle" style={{ marginBottom: 16 }} />
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', marginBottom: 8 }}>Ακύρωση Παραγγελίας;</p>
<p style={{ fontSize: 14, color: 'var(--muted)', marginBottom: 20 }}>Όλα τα ενεργά αντικείμενα θα ακυρωθούν και θα εκτυπωθεί απόδειξη ακύρωσης.</p>
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={() => setCancelConfirm(null)}>Ακύρωση</button>
<button className="btn" style={{ flex: 1, background: '#ef4444', color: '#fff' }} onClick={() => executeCancelOrder(cancelConfirm.orderId)}>Επιβεβαίωση</button>
</div>
</div>
</div>
)}
{emergencyPayModal && (
<EmergencyPayModal
table={emergencyPayModal.table}