import { useEffect, useRef, useState, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import TableCard from '../components/TableCard' import ConnectionBanner from '../components/ConnectionBanner' import EmergencyBar from '../components/EmergencyBar' import UserMenu from '../components/UserMenu' import useAuthStore from '../store/authStore' import useTableColourStore from '../store/tableColourStore' import useConnectionStore from '../store/connectionStore' import useTableViewStore from '../store/tableViewStore' import client from '../api/client' import db from '../db/posdb' import { queueOfflinePayment } from '../services/offlinePayments' import { useNotifications } from '../context/NotificationContext' import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons' function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' } // ─── Icons ──────────────────────────────────────────────────────────────────── function FilterIcon({ size = 20 }) { return ( ) } // ─── Notification drawer ────────────────────────────────────────────────────── function NotificationDrawer({ messages, onClose }) { return (
e.stopPropagation()} style={{ maxHeight: '80svh' }}>

Ειδοποιήσεις

{messages.length === 0 && (

Δεν υπάρχουν ειδοποιήσεις

)}
{messages.map(msg => { const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })() return (
📢
{msg.sender_name && (
{msg.sender_name}
)}
{msg.body}
{tableIds.length > 0 && (
Τραπέζι: {tableIds.join(', ')}
)}
{new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
) })}
) } // ─── Table quick-view modal (long press) ────────────────────────────────────── const QUICK_ACTIONS = [ { Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' }, { Icon: TransferIcon, label: 'Μεταφορά', key: 'transfer', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' }, { Icon: MergeIcon, label: 'Συγχώνευση', key: 'merge', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' }, { Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', key: 'print_synopsis', color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' }, { Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' }, ] function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) { 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) const paid = order?.payments?.reduce((s, p) => s + p.amount, 0) || 0 const due = Math.max(0, total - paid) const statusLabel = { open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο', }[order?.status] || 'Ελεύθερο' return (
e.stopPropagation()}>
{tableName} {statusLabel}
{order ? (
Σύνολο {fmtPrice(total)}
Πληρωμένο {fmtPrice(paid)}
{due > 0 && (
Υπόλοιπο {fmtPrice(due)}
)}
) : (

Δεν υπάρχει ενεργή παραγγελία

)} {flags.length > 0 && (
{flags.map(f => (
{f.emoji || '🏷️'} {f.name}
))}
)}

ACTIONS

{QUICK_ACTIONS.map((a, i) => { const disabled = !order && a.key !== 'flags' return ( ) })}
) } // ─── Emergency payment modal ────────────────────────────────────────────────── function EmergencyPayModal({ table, order, onClose, onPay }) { const [paying, setPaying] = useState(false) const activeItems = order?.items?.filter(i => i.status === 'active') || [] const total = activeItems.reduce((s, i) => s + (i.unit_price || 0) * (i.quantity || 1), 0) async function handlePay() { setPaying(true) await onPay(order.id, activeItems.map(i => i.id), 'cash') onClose() } return (
e.stopPropagation()} style={{ maxWidth: 400 }}>
🚨

ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ

Τραπέζι: {table.label || `T${table.number}`}

Ενεργά αντικείμενα:

{activeItems.length === 0 ?

Δεν υπάρχουν δεδομένα (offline snapshot)

: activeItems.map(item => (
{item.product?.name || `#${item.product_id}`} ×{item.quantity} {((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} €
)) }
Σύνολο {total.toFixed(2)} €
{total === 0 ?

Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.

:

⚠️ Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.

}
) } // ─── Filters modal ──────────────────────────────────────────────────────────── function FiltersModal({ groups, onClose }) { const { ownerFilter, statusFilter, zoneFilter, setOwnerFilter, setStatusFilter, setZoneFilter, clearFilters, setActiveZoneTab, } = useTableViewStore() function toggleZone(id) { const next = zoneFilter.includes(id) ? zoneFilter.filter(z => z !== id) : [...zoneFilter, id] setZoneFilter(next) // if we remove a zone that is the active tab, reset to 'all' if (!next.length) setActiveZoneTab('all') } const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0 return (
e.stopPropagation()} style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }} >
Φίλτρα {hasActiveFilters && ( )}
{/* Owner: ALL | MINE */}

Ανάθεση

{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => ( ))}
{/* Status: ALL | FREE | OPEN | PAID */}

Κατάσταση

{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => ( ))}
{/* Zones: multi-select, one segmented container per zone */} {groups.length > 0 && (

Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}

{groups.map(g => { const active = zoneFilter.includes(g.id) return (
) })}
)}
) } const sectionLabel = { fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 8 } const segmentedWrap = { display: 'flex', gap: 6, background: 'var(--bg3)', borderRadius: 12, padding: 4 } function segBtn(active) { return { flex: 1, padding: '9px 8px', borderRadius: 9, border: 'none', cursor: 'pointer', fontWeight: 600, fontSize: 14, background: active ? 'var(--accent)' : 'transparent', color: active ? 'var(--accent-fg)' : 'var(--muted)', transition: 'background 0.12s', } } // ─── Main page ──────────────────────────────────────────────────────────────── export default function TableListPage() { const { user } = useAuthStore() const { status: connStatus } = useConnectionStore() const isEmergency = connStatus === 'emergency' const [tables, setTables] = useState([]) const [groups, setGroups] = useState([]) const [orders, setOrders] = useState([]) const [flagDefs, setFlagDefs] = useState([]) const [flagAssignments, setFlagAssignments] = useState([]) const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup const [offline, setOffline] = useState(false) const [showNotifs, setShowNotifs] = useState(false) const [showFilters, setShowFilters] = useState(false) const [quickModal, setQuickModal] = useState(null) const [emergencyPayModal, setEmergencyPayModal] = useState(null) const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set()) // pull-to-refresh state const [pulling, setPulling] = useState(false) const [pullY, setPullY] = useState(0) const [refreshing, setRefreshing] = useState(false) const pullStart = useRef(null) const scrollRef = useRef(null) const PULL_THRESHOLD = 72 const navigate = useNavigate() const filterBtnRef = useRef(null) const { unreadCount, recentMessages, fetchRecent } = useNotifications() || {} const loadFromBackend = useTableColourStore(s => s.loadFromBackend) const { density, ownerFilter, statusFilter, zoneFilter, activeZoneTab, setActiveZoneTab, } = useTableViewStore() // ── Load from IndexedDB when offline ────────────────────────────────────── const loadFromDB = useCallback(async () => { const [dbTables, dbOrders] = await Promise.all([db.tables.toArray(), db.orders.toArray()]) setTables(dbTables.filter(t => t.is_active !== false)) setOrders(dbOrders) setOffline(true) }, []) useEffect(() => { if (isEmergency) loadFromDB() }, [isEmergency]) useEffect(() => { const handler = () => setOffline(true) window.addEventListener('backend-offline', handler) return () => window.removeEventListener('backend-offline', handler) }, []) useEffect(() => { const handler = () => load() window.addEventListener('sse-reconnected', handler) return () => window.removeEventListener('sse-reconnected', handler) }, []) useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus]) async function load() { try { const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes, waitersRes] = await Promise.all([ client.get('/api/tables/'), client.get('/api/orders/active'), client.get('/api/tables/groups'), client.get('/api/flags/defs'), client.get('/api/flags/assignments'), client.get('/api/settings/'), client.get('/api/waiters/on-shift'), ]) setTables(tablesRes.data) const fullOrders = await Promise.all( ordersRes.data.map(o => client.get(`/api/orders/${o.id}`) .then(r => ({ ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [] })) .catch(() => o) ) ) setOrders(fullOrders) setGroups(groupsRes.data) setFlagDefs(flagDefsRes.data) setFlagAssignments(flagAssignRes.data) setWaiters(waitersRes.data) const raw = settingsRes.data?.['ui.table_colours']?.value if (raw) loadFromBackend(raw) setOffline(false) } catch {} } useEffect(() => { load() }, []) // ── SSE live updates ─────────────────────────────────────────────────────── useEffect(() => { if (isEmergency) return function onSSE(e) { const { type, data } = e.detail if (type === 'order_updated' || type === 'order_paid') { client.get(`/api/orders/${data.order_id}`) .then(r => { const full = { ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? [] } setOrders(prev => { const exists = prev.find(o => o.id === data.order_id) return exists ? prev.map(o => o.id === data.order_id ? full : o) : [...prev, full] }) }) .catch(() => { setOrders(prev => { const existing = prev.find(o => o.id === data.order_id) if (existing) return prev.map(o => o.id === data.order_id ? { ...o, status: data.status, table_id: data.table_id } : o) return [...prev, { id: data.order_id, table_id: data.table_id, status: data.status, waiter_ids: [] }] }) }) } else if (type === 'order_closed') { setOrders(prev => prev.filter(o => o.id !== data.order_id)) } else if (type === 'table_flags_changed') { client.get('/api/flags/assignments').then(r => setFlagAssignments(r.data)).catch(() => {}) } else if (type === 'table_list_changed') { client.get('/api/tables/').then(r => setTables(r.data)).catch(() => {}) } } window.addEventListener('sse-event', onSSE) return () => window.removeEventListener('sse-event', onSSE) }, [isEmergency]) // ── Emergency payment ────────────────────────────────────────────────────── async function handleEmergencyPay(orderId, itemIds, paymentMethod) { await queueOfflinePayment({ orderId, itemIds, paymentMethod }) setLocalPaidOrderIds(prev => new Set([...prev, orderId])) setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'paid' } : o)) await db.orders.where('id').equals(orderId).modify({ status: 'paid' }) } // ── Derived maps ─────────────────────────────────────────────────────────── const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f])) const tableFlagsMap = {} flagAssignments.forEach(a => { if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = [] const def = flagDefMap[a.flag_id] if (def) tableFlagsMap[a.table_id].push(def) }) const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w])) function getOrder(tableId) { return orders.find(o => o.table_id === tableId) } function isMyOrder(order) { return !!(order && user && order.waiter_ids?.includes(user.id)) } function getOrderWaiters(order) { if (!order) return [] return (order.waiter_ids || []).map(id => waiterMap[id]).filter(Boolean) } // ── Filtering logic ──────────────────────────────────────────────────────── // Zones visible in top bar = those allowed by zoneFilter (or all if empty) const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null // visibleGroups = groups shown in the top bar const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id)) // Validate activeZoneTab against current allowedZoneIds // If the active tab is no longer visible, reset to 'all' const effectiveZoneTab = ( activeZoneTab === 'all' || visibleGroups.some(g => g.id === activeZoneTab) ) ? activeZoneTab : 'all' const filtered = tables.filter(t => { const order = getOrder(t.id) // Status filter if (statusFilter === 'free' && order) return false if (statusFilter === 'open' && (!order || order.status === 'paid' || order.status === 'partially_paid')) return false if (statusFilter === 'paid' && order?.status !== 'paid' && order?.status !== 'partially_paid') return false // Owner filter if (ownerFilter === 'mine' && !isMyOrder(order)) return false // Zone filter from modal (multi-select restricts which zones are allowed) if (allowedZoneIds && !allowedZoneIds.has(t.group_id ?? 'none')) return false // Active zone tab (secondary, single-select within allowed) if (effectiveZoneTab !== 'all' && t.group_id !== effectiveZoneTab) return false return true }) // ── Pull-to-refresh handlers ─────────────────────────────────────────────── function onPullTouchStart(e) { if (scrollRef.current?.scrollTop > 0) return pullStart.current = e.touches[0].clientY } function onPullTouchMove(e) { if (pullStart.current === null) return const dy = e.touches[0].clientY - pullStart.current if (dy > 0 && scrollRef.current?.scrollTop <= 0) { e.preventDefault() setPulling(true) setPullY(Math.min(dy, PULL_THRESHOLD * 1.5)) } } async function onPullTouchEnd() { if (!pulling) return if (pullY >= PULL_THRESHOLD) { setRefreshing(true) await load() setRefreshing(false) } setPulling(false) setPullY(0) pullStart.current = null } // ── Grid columns per density ─────────────────────────────────────────────── const gridCols = { '1x1': 'repeat(4, 1fr)', '2x1': 'repeat(2, 1fr)', '2x2': 'repeat(2, 1fr)', '4x1': '1fr', '4x2': '1fr', '4x3': '1fr', }[density] || 'repeat(2, 1fr)' const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0 function handleQuickAction(tableId, actionKey) { navigate(`/tables/${tableId}?action=${actionKey}`) } return (
Τραπέζια
{isEmergency ? : (offline && )} {/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
{/* ALL tab */} setActiveZoneTab('all')} /> {/* Per-zone tabs */} {visibleGroups.map(g => ( setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)} /> ))}
{/* ── Table grid ───────────────────────────────────────────────────────── */}
{/* Pull-to-refresh indicator */} {(pulling || refreshing) && (
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
)}
{filtered.map(t => { const order = getOrder(t.id) const tableFlags = tableFlagsMap[t.id] || [] const grp = groups.find(g => g.id === t.group_id) const alreadyPaidLocally = order && localPaidOrderIds.has(order.id) const orderWaiters = getOrderWaiters(order) function handleClick() { if (isEmergency) { if (order && !alreadyPaidLocally && order.status !== 'paid' && order.status !== 'closed') { setEmergencyPayModal({ table: t, order }) } return } const destination = order ? `/tables/${t.id}` : `/tables/${t.id}/add?new=1` navigate(destination) } return ( setQuickModal({ table: t, order, flags: tableFlags })} /> ) })}
{/* ── Filter FAB ───────────────────────────────────────────────────────── */} {/* ── Modals ────────────────────────────────────────────────────────────── */} {showNotifs && ( setShowNotifs(false)} /> )} {showFilters && ( setShowFilters(false)} anchorRef={filterBtnRef} /> )} {quickModal && ( setQuickModal(null)} onNavigate={() => navigate(`/tables/${quickModal.table.id}`)} onAction={(key) => handleQuickAction(quickModal.table.id, key)} /> )} {emergencyPayModal && ( setEmergencyPayModal(null)} onPay={handleEmergencyPay} /> )}
) } // ─── Zone tab pill ──────────────────────────────────────────────────────────── function ZoneTab({ label, color, active, onClick }) { return ( ) }