Files
simple-pos-system/waiter_pwa/src/pages/TableDetailPage.jsx

1222 lines
51 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useEffect, useState, useRef } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import OrderSummary from '../components/OrderSummary'
import useAuthStore from '../store/authStore'
import client from '../api/client'
import { TransferIcon, MergeIcon, FlagsIcon, WaiterIcon, PrintIcon } from '../components/Icons'
function fmtPrice(v) { return Number(v).toFixed(2) + ' €' }
// ─── Print results modal ──────────────────────────────────────────────────────
function PrintResultsModal({ results, 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>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
{results.map((r, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10,
background: r.success ? '#14532d' : '#431407',
border: `1px solid ${r.success ? '#22c55e' : '#c2410c'}`,
borderRadius: 10, padding: '10px 14px',
}}>
<span style={{ fontSize: 18 }}>{r.success ? '✓' : '✗'}</span>
<div>
<p style={{ fontWeight: 600, fontSize: 14, color: r.success ? '#86efac' : '#fdba74', margin: 0 }}>
{r.printer_name}
</p>
{!r.success && (
<p style={{ fontSize: 12, color: '#fdba74', margin: 0 }}>Εκτυπωτής μη προσβάσιμος</p>
)}
</div>
</div>
))}
</div>
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Κλείσιμο</button>
</div>
</div>
)
}
// ─── Split stepper modal ──────────────────────────────────────────────────────
function SplitModal({ item, onConfirm, onClose }) {
const [splitQty, setSplitQty] = useState(1)
const max = item.quantity - 1
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={{ textAlign: 'center', color: '#94a3b8', fontSize: 14, marginBottom: 4 }}>
{item.product?.name} ×{item.quantity}
</p>
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: 13, marginBottom: 16 }}>
Χώρισε σε πόσα;
</p>
<div className="modal-qty" style={{ marginBottom: 8 }}>
<button className="qty-btn" onClick={() => setSplitQty(q => Math.max(1, q - 1))}></button>
<span className="qty-value">{splitQty}</span>
<button className="qty-btn" onClick={() => setSplitQty(q => Math.min(max, q + 1))}>+</button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginBottom: 20, fontSize: 13, color: '#94a3b8' }}>
<span>Νέα γραμμή: <strong style={{ color: '#f59e0b' }}>×{splitQty}</strong></span>
<span>Μένει: <strong style={{ color: '#64748b' }}>×{item.quantity - splitQty}</strong></span>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Άκυρο</button>
<button className="btn btn--primary" style={{ flex: 1 }} onClick={() => onConfirm(splitQty)}>
Διαχωρισμός
</button>
</div>
</div>
</div>
)
}
// ─── Item action modal (long-press) ──────────────────────────────────────────
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
const { items, singleStacked, multiSelect } = target
const label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα`
: items[0]?.product?.name || `#${items[0]?.product_id}`
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 && (
<button
onClick={onSplit}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(96,165,250,0.15)',
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>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
)}
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
Άκυρο
</button>
</div>
</div>
)
}
// ─── Actions top sheet ────────────────────────────────────────────────────────
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
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)' },
]
return (
<div className="modal-overlay modal-overlay--top" onClick={onClose}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
<div className="modal-handle" />
<h2 className="modal-title" style={{ marginBottom: 16 }}>ACTIONS</h2>
{actions.map((a, i) => (
<button
key={i}
onClick={() => { a.onClick?.(); onClose() }}
disabled={!a.onClick}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 16,
padding: '14px 0', background: 'none', border: 'none',
borderBottom: i < actions.length - 1 ? '1px solid var(--border)' : 'none',
cursor: a.onClick ? 'pointer' : 'not-allowed',
opacity: a.onClick ? 1 : 0.35, textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: a.color,
}}>
<a.Icon width="20" height="20" />
</span>
<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>
{a.onClick && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>}
</button>
))}
</div>
</div>
)
}
// ─── Table picker (for transfer / merge / move-items) ─────────────────────────
function TablePicker({ title, subtitle, tables, currentTableId, onSelect, onClose, loading }) {
return (
<div className="modal-overlay modal-overlay--top" onClick={onClose}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">{title}</h2>
{subtitle && <p style={{ textAlign: 'center', color: '#94a3b8', fontSize: 13, marginBottom: 4 }}>{subtitle}</p>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, maxHeight: '60svh', overflowY: 'auto' }}>
{loading && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 24 }}>Φόρτωση</p>
)}
{!loading && tables.length === 0 && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 24 }}>Δεν υπάρχουν διαθέσιμα τραπέζια</p>
)}
{!loading && tables.map(t => (
<button
key={t.id}
onClick={() => onSelect(t)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 4px', background: 'none', border: 'none',
borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
{t.label || `T${t.number}`}
</div>
{t.orderStatus && (
<div style={{ fontSize: 12, color: '#f59e0b', marginTop: 2 }}>
{t.orderStatus === 'open' ? 'Ανοιχτό' : t.orderStatus === 'partially_paid' ? 'Μερικώς πληρωμένο' : t.orderStatus}
</div>
)}
</div>
<span style={{ color: 'var(--muted)', fontSize: 18 }}></span>
</button>
))}
</div>
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 8 }} onClick={onClose}>Άκυρο</button>
</div>
</div>
)
}
// ─── Flag picker ──────────────────────────────────────────────────────────────
function FlagPicker({ tableId, currentFlagIds, flagDefs, onSave, onClose, loading }) {
const [selected, setSelected] = useState(currentFlagIds || [])
// Sync selected when currentFlagIds loads
useEffect(() => { setSelected(currentFlagIds || []) }, [currentFlagIds.join(',')])
function toggle(id) {
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
return (
<div className="modal-overlay modal-overlay--top" onClick={onClose}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">Ενδείξεις Τραπεζιού</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, marginBottom: 20 }}>
{loading && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>Φόρτωση</p>
)}
{!loading && flagDefs.length === 0 && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>Δεν υπάρχουν σημαίες</p>
)}
{!loading && flagDefs.map(f => {
const sel = selected.includes(f.id)
return (
<button
key={f.id}
onClick={() => toggle(f.id)}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 4px', background: 'none', border: 'none',
borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
opacity: sel ? 1 : 0.7,
}}
>
<div style={{
width: 36, height: 36, borderRadius: '50%', flexShrink: 0,
background: f.color + '33', border: `2px solid ${sel ? f.color : 'transparent'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18,
}}>
{f.emoji || '🏷️'}
</div>
<span style={{ flex: 1, fontSize: 15, fontWeight: sel ? 700 : 500, color: sel ? f.color : 'var(--text)' }}>
{f.name}
</span>
<span style={{ fontSize: 20, color: sel ? f.color : 'var(--muted)' }}>
{sel ? '✓' : '○'}
</span>
</button>
)
})}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Άκυρο</button>
<button className="btn btn--primary" style={{ flex: 1 }} onClick={() => onSave(selected)}>Αποθήκευση</button>
</div>
</div>
</div>
)
}
// ─── Assign waiter picker ─────────────────────────────────────────────────────
function AssignWaiterPicker({ orderId, currentWaiterIds, waiters, onAssigned, onClose, loading }) {
const [saving, setSaving] = useState(false)
async function assign(waiterId) {
setSaving(true)
try {
await client.put(`/api/orders/${orderId}/assign-waiter`, { waiter_id: waiterId })
onAssigned()
onClose()
} catch {
// already assigned or error — just close
onClose()
} finally {
setSaving(false)
}
}
const available = waiters.filter(w => !currentWaiterIds.includes(w.id))
return (
<div className="modal-overlay modal-overlay--top" onClick={onClose}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">Ανάθεση Σερβιτόρου</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, marginBottom: 16, maxHeight: '60svh', overflowY: 'auto' }}>
{loading && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>Φόρτωση</p>
)}
{!loading && available.length === 0 && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>Όλοι οι σερβιτόροι έχουν ήδη ανατεθεί</p>
)}
{!loading && available.map(w => (
<button
key={w.id}
onClick={() => assign(w.id)}
disabled={saving}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 4px', background: 'none', border: 'none',
borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
}}
>
<div style={{
width: 36, height: 36, borderRadius: '50%', flexShrink: 0,
background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 700, color: 'var(--text)',
}}>
{(w.nickname || w.username).charAt(0).toUpperCase()}
</div>
<span style={{ fontSize: 15, color: 'var(--text)' }}>{w.nickname || w.full_name || w.username}</span>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
))}
</div>
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Άκυρο</button>
</div>
</div>
)
}
// ─── Print synopsis picker ────────────────────────────────────────────────────
function PrintSynopsisPicker({ orderId, onClose }) {
const [printers, setPrinters] = useState([])
const [printing, setPrinting] = useState(false)
useEffect(() => {
client.get('/api/system/status').then(r => setPrinters(r.data?.printers || [])).catch(() => {})
}, [])
async function print(printerId) {
setPrinting(true)
try {
await client.post(`/api/orders/${orderId}/print-synopsis`, { printer_id: printerId })
onClose()
} catch {
onClose()
} finally {
setPrinting(false)
}
}
return (
<div className="modal-overlay modal-overlay--top" onClick={onClose}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">Εκτύπωση Σύνοψης</h2>
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: 13, marginBottom: 8 }}>Επιλέξτε εκτυπωτή</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, marginBottom: 16 }}>
{printers.filter(p => p.reachable).map(p => (
<button
key={p.id}
onClick={() => print(p.id)}
disabled={printing}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 4px', background: 'none', border: 'none',
borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{ fontSize: 20 }}>🖨</span>
<span style={{ fontSize: 15, color: 'var(--text)' }}>{p.name}</span>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
))}
{printers.filter(p => !p.reachable).map(p => (
<div key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 4px', borderBottom: '1px solid var(--border)', opacity: 0.4 }}>
<span style={{ fontSize: 20 }}>🖨</span>
<span style={{ fontSize: 15, color: 'var(--text)' }}>{p.name}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#ef4444' }}>Offline</span>
</div>
))}
</div>
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Άκυρο</button>
</div>
</div>
)
}
// ─── Pay confirm modal ────────────────────────────────────────────────────────
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
const payTotal = activeItems
.filter(i => payIds.includes(i.id))
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
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: 4 }}>
{payAll ? 'Όλα τα αντικείμενα' : `${payIds.length} αντικείμενο${payIds.length !== 1 ? 'α' : ''}`}
</p>
<p style={{ color: '#f59e0b', textAlign: 'center', fontSize: 28, fontWeight: 700, marginBottom: 24 }}>
{fmtPrice(payTotal)}
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>
Άκυρο
</button>
<button className="btn btn--success" style={{ flex: 1 }} onClick={onConfirm}>
Πληρώθηκαν
</button>
</div>
</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function TableDetailPage() {
const { tableId } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuthStore()
const navigate = useNavigate()
const [table, setTable] = useState(null)
const [order, setOrder] = useState(null)
const [loading, setLoading] = useState(true)
const [paying, setPaying] = useState(false)
const [selectedIds, setSelectedIds] = useState([])
const [confirmClose, setConfirmClose] = useState(false)
const [confirmPay, setConfirmPay] = useState(false)
const [error, setError] = useState('')
const [printResults, setPrintResults] = useState(null)
const [retrying, setRetrying] = useState(false)
// Tab: 'active' | 'paid'
const [activeTab, setActiveTab] = useState('active')
// Actions sheet state
const [showActions, setShowActions] = useState(false)
const [actionsMode, setActionsMode] = useState(null)
// actionsMode: null | 'transfer' | 'merge' | 'flags' | 'assign_waiter' | 'print_synopsis' | 'split' | 'move_items'
const [pendingTable, setPendingTable] = useState(null)
// pendingTable: { table, mode: 'transfer'|'merge'|'move_items' } — waiting for confirmation
// Data for sub-flows
const [allTables, setAllTables] = useState([])
const [allOrders, setAllOrders] = useState([])
const [flagDefs, setFlagDefs] = useState([])
const [currentFlagIds, setCurrentFlagIds] = useState([])
const [allWaiters, setAllWaiters] = useState([])
const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null)
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
const scrollRef = useRef(null)
async function load() {
setLoading(true)
try {
const [statusRes, flagDefsRes, flagAssignRes] = await Promise.all([
client.get(`/api/tables/${tableId}/status`),
client.get('/api/flags/defs'),
client.get(`/api/flags/table/${tableId}`),
])
setTable(statusRes.data.table)
if (statusRes.data.active_order_id) {
const { data: o } = await client.get(`/api/orders/${statusRes.data.active_order_id}`)
setOrder(o)
} else {
setOrder(null)
}
setFlagDefs(flagDefsRes.data)
setCurrentFlagIds(flagAssignRes.data.map(a => a.flag_id))
} catch {
setError('Σφάλμα φόρτωσης')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [tableId])
// Handle ?action= param from table list long-press quick actions
useEffect(() => {
const action = searchParams.get('action')
if (!action || loading) return
setSearchParams({}, { replace: true })
// Load supporting data, then jump straight to the action mode
loadActionData()
setActionsMode(action)
}, [loading])
// Load supporting data for sub-flows (lazy, only when Actions opened)
async function loadActionData() {
setActionDataLoading(true)
const [tablesRes, ordersRes, waitersRes] = await Promise.allSettled([
client.get('/api/tables/'),
client.get('/api/orders/active'),
client.get('/api/waiters/on-shift'),
])
if (tablesRes.status === 'fulfilled') setAllTables(tablesRes.value.data)
if (ordersRes.status === 'fulfilled') setAllOrders(ordersRes.value.data)
if (waitersRes.status === 'fulfilled') setAllWaiters(waitersRes.value.data)
setActionDataLoading(false)
}
function openActions() {
setAllTables([])
setAllOrders([])
setAllWaiters([])
loadActionData()
setShowActions(true)
}
const activeItems = order?.items?.filter(i => i.status === 'active') || []
const paidItems = order?.items?.filter(i => i.status === 'paid') || []
const unprintedItems = activeItems.filter(i => !i.printed)
const allPaid = order && activeItems.length === 0
const canInteract = !!order
const allActiveSelected = activeItems.length > 0 && activeItems.every(i => selectedIds.includes(i.id))
async function sendDraftToKitchen() {
if (!order || retrying) return
setRetrying(true)
try {
const res = await client.post(`/api/orders/${order.id}/retry-print`)
setPrintResults(res.data.print_results ?? [])
await load()
} catch {
setError('Σφάλμα κατά την εκτύπωση')
} finally {
setRetrying(false)
}
}
async function openOrder() {
try {
await client.post('/api/orders/', { table_id: Number(tableId) })
await load()
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα ανοίγματος')
}
}
async function paySelected() {
setPaying(true)
setConfirmPay(false)
const idsToPayNow = selectedIds.length > 0 ? selectedIds : activeItems.map(i => i.id)
try {
await client.post(`/api/orders/${order.id}/pay`, { item_ids: idsToPayNow })
setSelectedIds([])
await load()
} catch {
setError('Σφάλμα πληρωμής')
} finally {
setPaying(false)
}
}
async function closeOrder() {
try {
await client.post(`/api/orders/${order.id}/close`)
setConfirmClose(false)
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα κλεισίματος')
}
}
function toggleItem(id) {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
function selectAll() {
const allActive = activeItems.map(i => i.id)
const allSelected = allActive.every(id => selectedIds.includes(id))
setSelectedIds(allSelected ? [] : allActive)
}
// Split item: called from SplitModal
async function doSplit(qty) {
if (!splitItem || !order) return
setSplitItem(null)
try {
await client.post(`/api/orders/${order.id}/items/${splitItem.id}/split`, { quantity: qty })
setSelectedIds([])
await load()
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα διαχωρισμού')
}
}
// Transfer table
async function doTransfer(targetTable) {
if (!order) return
setPendingTable(null)
setActionsMode(null)
try {
await client.post(`/api/orders/${order.id}/transfer`, { target_table_id: targetTable.id })
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα μεταφοράς')
}
}
// Merge table
async function doMerge(targetTable) {
if (!order) return
const targetOrder = allOrders.find(o => o.table_id === targetTable.id)
if (!targetOrder) { setError('Δεν βρέθηκε παραγγελία στο τραπέζι'); return }
setPendingTable(null)
setActionsMode(null)
try {
await client.post(`/api/orders/${order.id}/merge`, { target_order_id: targetOrder.id })
navigate('/tables')
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα συγχώνευσης')
}
}
// Move items to another table
async function doMoveItems(targetTable) {
if (!order || selectedIds.length === 0) return
const targetOrder = allOrders.find(o => o.table_id === targetTable.id)
if (!targetOrder) { setError('Δεν βρέθηκε παραγγελία στο τραπέζι'); return }
setPendingTable(null)
setActionsMode(null)
try {
await client.post(`/api/orders/${order.id}/move-items`, {
item_ids: selectedIds,
target_order_id: targetOrder.id,
})
setSelectedIds([])
await load()
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα μεταφοράς αντικειμένων')
}
}
// Save flags
async function doSaveFlags(flagIds) {
try {
await client.put(`/api/flags/table/${tableId}`, { flag_ids: flagIds })
setCurrentFlagIds(flagIds)
setActionsMode(null)
} catch {
setError('Σφάλμα αποθήκευσης σημαιών')
}
}
// Tables for transfer: active tables that are free (no active order), excluding current
const transferTargets = allTables.filter(t =>
t.id !== Number(tableId) &&
t.is_active &&
!allOrders.some(o => o.table_id === t.id)
)
// Tables for merge: tables with active orders, excluding current
const mergeTargets = allTables
.filter(t => t.id !== Number(tableId) && t.is_active)
.map(t => {
const o = allOrders.find(ord => ord.table_id === t.id)
return o ? { ...t, orderStatus: o.status } : null
})
.filter(Boolean)
// Tables for move-items: tables with open/partially_paid orders, excluding current
const moveItemsTargets = allTables
.filter(t => t.id !== Number(tableId) && t.is_active)
.map(t => {
const o = allOrders.find(ord => ord.table_id === t.id)
return o && (o.status === 'open' || o.status === 'partially_paid') ? { ...t, orderStatus: o.status } : null
})
.filter(Boolean)
const tableName = table ? (table.label || `T${table.number}`) : '—'
if (loading) return <div className="page page--centered"><p style={{ color: '#94a3b8' }}>Φόρτωση</p></div>
return (
<div className="page">
{/* Top bar */}
<header className="top-bar">
<button className="icon-btn" onClick={() => navigate('/tables')}></button>
<span className="top-bar__title">{tableName}</span>
<button
className="icon-btn"
onClick={openActions}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13, fontWeight: 700, color: 'var(--accent)', minWidth: 'unset', padding: '0 8px' }}
>
<span style={{ fontSize: 16 }}></span>
<span>ACTIONS</span>
</button>
</header>
{error && <p className="error-msg" style={{ margin: 12 }}>{error}</p>}
{/* Active flag cards */}
{currentFlagIds.length > 0 && flagDefs.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px 0' }}>
{currentFlagIds.map(fid => {
const def = flagDefs.find(d => d.id === fid)
if (!def) return null
return (
<button
key={fid}
onClick={() => doSaveFlags(currentFlagIds.filter(id => id !== fid))}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: (def.color || '#6295F3') + '22',
border: `1px solid ${def.color || '#6295F3'}`,
borderRadius: 12, cursor: 'pointer', textAlign: 'left',
width: '100%',
}}
>
<span style={{ fontSize: 20 }}>{def.emoji || '🏷️'}</span>
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: def.color || '#6295F3' }}>{def.name}</span>
<span style={{ fontSize: 18, color: def.color || '#6295F3', opacity: 0.6 }}></span>
</button>
)
})}
</div>
)}
{!order && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<p style={{ color: '#94a3b8', marginBottom: 24 }}>Δεν υπάρχει ενεργή παραγγελία</p>
<button className="btn btn--primary btn--lg" onClick={openOrder}>
Άνοιγμα Παραγγελίας
</button>
</div>
)}
{order && (
<div
className="detail-body"
ref={scrollRef}
style={{ overflowY: 'auto' }}
>
{/* Unprinted items warning */}
{unprintedItems.length > 0 && (
<div style={{
margin: '10px 12px 0',
background: '#431407', border: '1px solid #c2410c',
borderRadius: 12, padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 20 }}></span>
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 700, fontSize: 13, color: '#fdba74', margin: 0 }}>
{unprintedItems.length} αντικείμενο{unprintedItems.length !== 1 ? 'α' : ''} δεν εκτυπώθηκε{unprintedItems.length !== 1 ? 'αν' : ''}
</p>
<p style={{ fontSize: 12, color: '#fdba74', margin: 0, opacity: 0.8 }}>
Δεν έχουν σταλεί στην κουζίνα/μπαρ
</p>
</div>
<button
className="btn btn--primary"
style={{ fontSize: 12, padding: '6px 12px', flexShrink: 0, opacity: retrying ? 0.7 : 1 }}
onClick={sendDraftToKitchen}
disabled={retrying}
>
{retrying ? '…' : 'Αποστολή'}
</button>
</div>
)}
{/* ── Tabs ── */}
<div style={{
display: 'flex', gap: 0,
margin: '10px 12px 0',
background: 'var(--bg2)',
borderRadius: 10,
border: '1px solid var(--border)',
overflow: 'hidden',
}}>
<button
onClick={() => setActiveTab('active')}
style={{
flex: 1, padding: '9px 0', border: 'none', cursor: 'pointer',
fontWeight: 700, fontSize: 13,
background: activeTab === 'active' ? 'var(--accent)' : 'transparent',
color: activeTab === 'active' ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.15s',
}}
>
Εκκρεμή {activeItems.length > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: activeTab === 'active' ? 'rgba(0,0,0,0.2)' : 'var(--bg3)',
borderRadius: 999, fontSize: 11, fontWeight: 800,
padding: '1px 7px', marginLeft: 4,
}}>{activeItems.length}</span>
)}
</button>
<button
onClick={() => setActiveTab('paid')}
style={{
flex: 1, padding: '9px 0', border: 'none', cursor: 'pointer',
fontWeight: 700, fontSize: 13,
background: activeTab === 'paid' ? '#14532d' : 'transparent',
color: activeTab === 'paid' ? '#86efac' : 'var(--muted)',
transition: 'background 0.15s',
}}
>
Πληρωμένα {paidItems.length > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: activeTab === 'paid' ? 'rgba(0,0,0,0.2)' : 'var(--bg3)',
borderRadius: 999, fontSize: 11, fontWeight: 800,
padding: '1px 7px', marginLeft: 4,
}}>{paidItems.length}</span>
)}
</button>
</div>
{activeTab === 'active' && (
<>
<OrderSummary
order={{ ...order, items: activeItems }}
selectable={canInteract && !paying}
selectedIds={selectedIds}
onToggle={toggleItem}
onLongPressItem={(item) => {
// If multiple items are selected, order-again all selected items
if (selectedIds.length > 1) {
const items = activeItems.filter(i => selectedIds.includes(i.id))
setItemActionTarget({ items, singleStacked: false, multiSelect: true })
} else {
setItemActionTarget({ items: [item], singleStacked: item.quantity > 1, multiSelect: false })
}
}}
/>
{/* Floating controls row — only visible when items are selected */}
{canInteract && activeItems.length > 0 && selectedIds.length > 0 && (
<div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
padding: '10px 12px 24px',
}}>
{/* Clear selection */}
<button
onClick={() => setSelectedIds([])}
style={{
display: 'inline-flex', alignItems: 'center',
height: 36, padding: '0 16px',
borderRadius: 999,
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
}}
>
καθ. επιλ.
</button>
{/* Select all — hidden once everything is already selected */}
{!allActiveSelected && (
<button
onClick={selectAll}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px',
borderRadius: 999,
background: 'rgba(34,197,94,0.18)', color: '#86efac',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
}}
>
όλα
<span style={{
background: 'rgba(34,197,94,0.35)', color: '#86efac',
borderRadius: 999, fontSize: 11, fontWeight: 700,
padding: '1px 6px',
}}>{activeItems.length}</span>
</button>
)}
{/* Transfer items */}
<button
onClick={() => {
setAllTables([])
setAllOrders([])
loadActionData()
setActionsMode('move_items')
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px',
borderRadius: 999,
background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
}}
>
μεταφορά
<span style={{
background: 'rgba(96,165,250,0.35)', color: '#93c5fd',
borderRadius: 999, fontSize: 11, fontWeight: 700,
padding: '1px 6px',
}}>{selectedIds.length}</span>
</button>
</div>
)}
</>
)}
{activeTab === 'paid' && (
<div style={{ padding: '8px 0' }}>
{paidItems.length === 0 ? (
<p style={{ color: '#64748b', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>
Δεν υπάρχουν πληρωμένα αντικείμενα
</p>
) : (
<OrderSummary
order={{ ...order, items: paidItems }}
selectable={false}
selectedIds={[]}
onToggle={() => {}}
/>
)}
</div>
)}
</div>
)}
{/* Action bar — sticky at bottom, only when there's an active order on the active tab */}
{order && canInteract && activeTab === 'active' && (
<div className="action-bar">
<button
className="btn btn--accent"
onClick={() => navigate(`/tables/${tableId}/add`)}
style={{ borderRadius: 999, flex: 1 }}
>
<span style={{ marginRight: 6 }}></span>ADD
</button>
<button
className="btn btn--success"
onClick={() => setConfirmPay(true)}
disabled={paying}
style={{ borderRadius: 999, flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
{paying ? '…' : (
<>
<span> PAY</span>
{selectedIds.length > 0 && (
<span style={{
background: 'rgba(0,0,0,0.25)', color: 'white',
borderRadius: 999, fontSize: 11, fontWeight: 700,
padding: '1px 7px',
}}>{selectedIds.length}</span>
)}
</>
)}
</button>
<button
className="btn btn--danger"
onClick={() => setConfirmClose(true)}
disabled={!allPaid}
style={{ borderRadius: 999, flex: 1 }}
>
<span style={{ marginRight: 6 }}></span>CLOSE
</button>
</div>
)}
{/* Item action modal (long-press) */}
{itemActionTarget && (
<ItemActionModal
target={itemActionTarget}
onOrderAgain={() => {
const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify(
items.map(it => ({
product_id: it.product_id,
quantity: it.quantity,
selected_options: (() => { try { return JSON.parse(it.selected_options || '[]') } catch { return [] } })(),
removed_ingredients: (() => { try { return JSON.parse(it.removed_ingredients || '[]') } catch { return [] } })(),
notes: it.notes || '',
}))
))
setItemActionTarget(null)
navigate(`/tables/${tableId}/add`)
}}
onSplit={() => {
setSplitItem(itemActionTarget.items[0])
setItemActionTarget(null)
}}
onClose={() => setItemActionTarget(null)}
/>
)}
{/* Split stepper modal */}
{splitItem && (
<SplitModal
item={splitItem}
onConfirm={doSplit}
onClose={() => setSplitItem(null)}
/>
)}
{/* Pay confirmation */}
{confirmPay && (
<PayConfirmModal
payAll={selectedIds.length === 0}
payIds={selectedIds.length === 0 ? activeItems.map(i => i.id) : selectedIds}
activeItems={activeItems}
onConfirm={paySelected}
onClose={() => setConfirmPay(false)}
/>
)}
{/* Close confirmation */}
{confirmClose && (
<div className="modal-overlay" onClick={() => setConfirmClose(false)}>
<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 }}>
Αυτή η ενέργεια δεν αναιρείται.
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={() => setConfirmClose(false)}>
Άκυρο
</button>
<button className="btn btn--danger" style={{ flex: 1 }} onClick={closeOrder}>
CLOSE
</button>
</div>
</div>
</div>
)}
{/* Print retry results */}
{printResults && (
<PrintResultsModal results={printResults} onClose={() => setPrintResults(null)} />
)}
{/* Actions top sheet */}
{showActions && actionsMode === null && (
<ActionsSheet
order={order}
tableId={tableId}
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') }}
/>
)}
{/* Transfer picker */}
{actionsMode === 'transfer' && !pendingTable && (
<TablePicker
title="Μεταφορά Τραπεζιού"
subtitle="Επιλέξτε κενό τραπέζι"
tables={transferTargets}
currentTableId={Number(tableId)}
onSelect={t => setPendingTable({ table: t, mode: 'transfer' })}
onClose={() => setActionsMode(null)}
loading={actionDataLoading}
/>
)}
{/* Merge picker */}
{actionsMode === 'merge' && !pendingTable && (
<TablePicker
title="Συγχώνευση Τραπεζιού"
subtitle="Τα αντικείμενα μεταφέρονται στο επιλεγμένο τραπέζι"
tables={mergeTargets}
currentTableId={Number(tableId)}
onSelect={t => setPendingTable({ table: t, mode: 'merge' })}
onClose={() => setActionsMode(null)}
loading={actionDataLoading}
/>
)}
{/* Move items picker */}
{actionsMode === 'move_items' && !pendingTable && (
<TablePicker
title="Μεταφορά Αντικειμένων"
subtitle={`Μεταφορά ${selectedIds.length} αντικειμένου${selectedIds.length !== 1 ? 'ων' : ''} σε τραπέζι`}
tables={moveItemsTargets}
currentTableId={Number(tableId)}
onSelect={t => setPendingTable({ table: t, mode: 'move_items' })}
onClose={() => setActionsMode(null)}
loading={actionDataLoading}
/>
)}
{/* Transfer / Merge / Move-items confirmation */}
{pendingTable && (
<div className="modal-overlay modal-overlay--top" onClick={() => setPendingTable(null)}>
<div className="modal-sheet modal-sheet--top" onClick={e => e.stopPropagation()}>
<div className="modal-handle" />
<h2 className="modal-title">
{pendingTable.mode === 'transfer' ? 'Επιβεβαίωση Μεταφοράς'
: pendingTable.mode === 'merge' ? 'Επιβεβαίωση Συγχώνευσης'
: 'Επιβεβαίωση Μεταφοράς Αντικειμένων'}
</h2>
<p style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14, marginBottom: 4 }}>
{pendingTable.mode === 'transfer' ? 'Μεταφορά παραγγελίας στο τραπέζι'
: pendingTable.mode === 'merge' ? 'Συγχώνευση παραγγελίας με το τραπέζι'
: `Μεταφορά ${selectedIds.length} αντικειμένου${selectedIds.length !== 1 ? 'ων' : ''} στο τραπέζι`}
</p>
<p style={{ textAlign: 'center', fontSize: 22, fontWeight: 700, color: 'var(--accent)', marginBottom: 20 }}>
{pendingTable.table.label || `T${pendingTable.table.number}`}
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={() => setPendingTable(null)}>
Άκυρο
</button>
<button
className="btn btn--primary"
style={{ flex: 1 }}
onClick={() => {
if (pendingTable.mode === 'transfer') doTransfer(pendingTable.table)
else if (pendingTable.mode === 'merge') doMerge(pendingTable.table)
else doMoveItems(pendingTable.table)
}}
>
{pendingTable.mode === 'transfer' ? 'Μεταφορά ✓'
: pendingTable.mode === 'merge' ? 'Συγχώνευση ✓'
: 'Μεταφορά ✓'}
</button>
</div>
</div>
</div>
)}
{/* Flag picker */}
{actionsMode === 'flags' && (
<FlagPicker
tableId={tableId}
currentFlagIds={currentFlagIds}
flagDefs={flagDefs}
onSave={doSaveFlags}
onClose={() => setActionsMode(null)}
loading={actionDataLoading}
/>
)}
{/* Assign waiter */}
{actionsMode === 'assign_waiter' && order && (
<AssignWaiterPicker
orderId={order.id}
currentWaiterIds={order.waiters.map(w => w.waiter_id)}
waiters={allWaiters}
onAssigned={load}
onClose={() => setActionsMode(null)}
loading={actionDataLoading}
/>
)}
{/* Print synopsis */}
{actionsMode === 'print_synopsis' && order && (
<PrintSynopsisPicker
orderId={order.id}
onClose={() => setActionsMode(null)}
/>
)}
</div>
)
}