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:
@@ -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' }) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user