Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc

This commit is contained in:
2026-05-02 21:08:53 +03:00
parent 8e27b7666e
commit c9ad78ec71
50 changed files with 4441 additions and 643 deletions

View File

@@ -4,13 +4,17 @@ import useAuthStore from './store/authStore'
import useShiftStore from './store/shiftStore'
import useThemeStore from './store/themeStore'
import useTableColourStore from './store/tableColourStore'
import useConnectionStore from './store/connectionStore'
import client from './api/client'
import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage'
import AddItemsPage from './pages/AddItemsPage'
import OfflinePage from './pages/OfflinePage'
import SettingsPage from './pages/SettingsPage'
import { NotificationProvider } from './context/NotificationContext'
import { SSEProvider } from './context/SSEContext'
import ConnectionLostModal from './components/ConnectionLostModal'
// ─── Utility ─────────────────────────────────────────────────────────────────
@@ -269,11 +273,18 @@ function AuthRehydrator() {
function OfflineListener() {
const navigate = useNavigate()
const { token } = useAuthStore()
const { status } = useConnectionStore()
useEffect(() => {
const handler = () => navigate('/offline')
function handler() {
// If user is logged in, ConnectionLostModal handles it — don't redirect to /offline
if (token && status !== 'online') return
// Not logged in and server is down → redirect to offline page
if (!token) navigate('/offline')
}
window.addEventListener('backend-offline', handler)
return () => window.removeEventListener('backend-offline', handler)
}, [navigate])
}, [navigate, token, status])
return null
}
@@ -307,18 +318,22 @@ export default function App() {
<ColourLoader />
<AuthRehydrator />
<OfflineListener />
<NotificationProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/offline" element={<OfflinePage />} />
<Route element={<AppLayout />}>
<Route path="/tables" element={<TableListPage />} />
<Route path="/tables/:tableId" element={<TableDetailPage />} />
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
</Route>
<Route path="*" element={<Navigate to="/tables" replace />} />
</Routes>
</NotificationProvider>
<SSEProvider>
<NotificationProvider>
<ConnectionLostModal />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/offline" element={<OfflinePage />} />
<Route element={<AppLayout />}>
<Route path="/tables" element={<TableListPage />} />
<Route path="/tables/:tableId" element={<TableDetailPage />} />
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/tables" replace />} />
</Routes>
</NotificationProvider>
</SSEProvider>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
import client from '../api/client'
import { useSSEContext } from '../context/SSEContext'
const RETRY_INTERVAL = 10_000 // 10s auto-retry while modal is open in Wait mode
export default function ConnectionLostModal() {
const { status, setOnline, enterEmergency } = useConnectionStore()
const { reconnect, fullRefresh } = useSSEContext()
const [retrying, setRetrying] = useState(false)
const retryRef = useRef(null)
const isVisible = status === 'lost'
async function tryReconnect() {
setRetrying(true)
try {
await client.get('/api/system/health')
// Server is back
setOnline()
reconnect()
await fullRefresh()
} catch {
// Still down — stay in modal
} finally {
setRetrying(false)
}
}
// Auto-retry every 10s while modal is open
useEffect(() => {
if (!isVisible) {
clearInterval(retryRef.current)
return
}
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
return () => clearInterval(retryRef.current)
}, [isVisible])
if (!isVisible) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 99999,
background: 'rgba(0,0,0,0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}>
<div style={{
background: '#1e293b',
border: '2px solid #ef4444',
borderRadius: 20,
padding: '32px 28px',
maxWidth: 400, width: '100%',
textAlign: 'center',
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<p style={{
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
marginBottom: 10,
}}>
Χάθηκε η σύνδεση με τον Manager
</p>
<p style={{
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
marginBottom: 28,
}}>
Δεν μπορώ να φτάσω στον server.{'\n'}
Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'}
για να συνεχίσεις με τοπικά δεδομένα.
</p>
<div style={{
display: 'flex', gap: 12, justifyContent: 'center',
}}>
<button
onClick={enterEmergency}
style={{
flex: 1,
height: 48, borderRadius: 12, border: 'none',
background: '#dc2626', color: '#fff',
fontSize: 15, fontWeight: 700,
cursor: 'pointer',
}}
>
EMERGENCY MODE
</button>
</div>
<p style={{ fontSize: 11, color: '#475569', marginTop: 16 }}>
Αυτόματη επανάληψη κάθε 10 δευτερόλεπτα
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
export default function EmergencyBar() {
const { status, lostAt } = useConnectionStore()
const [elapsed, setElapsed] = useState('')
useEffect(() => {
if (status !== 'emergency' || !lostAt) return
function tick() {
const secs = Math.floor((Date.now() - lostAt.getTime()) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
setElapsed(`${m}:${String(s).padStart(2, '0')}`)
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [status, lostAt])
if (status !== 'emergency') return null
return (
<div style={{
background: '#dc2626',
color: '#fef08a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 8,
padding: '8px 16px',
fontSize: 13, fontWeight: 700,
letterSpacing: 0.5,
userSelect: 'none',
}}>
<span>EMERGENCY MODE</span>
{elapsed && (
<span style={{ opacity: 0.85, fontWeight: 400 }}>({elapsed})</span>
)}
</div>
)
}

View File

@@ -147,20 +147,20 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const prefChoices = preferenceSets.flatMap(ps => {
const choice = selectedPreferences[ps.id]
if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = selectedSharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
}
return entries
})
const optionEntries = selectedOptions.flatMap(o => {
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }]
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0, type: 'extra' }]
const sub = selectedOptionSubs[o.id]
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
return entries
})

View File

@@ -715,9 +715,9 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
return entries
})
@@ -727,8 +727,8 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const entries = []
for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
}
return entries
})
@@ -736,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 }))
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
})
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)

View File

@@ -73,12 +73,11 @@ function buildSections(parent, subcategories, directProducts) {
return sections.sort((a, b) => a.sort_order - b.sort_order)
}
export default function ProductPicker({ categories, products, onAdd }) {
export default function ProductPicker({ categories, products, onAdd, viewAllOpen, setViewAllOpen }) {
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const initialCatId = topLevel[0]?.id ?? null
const [activeCat, setActiveCat] = useState(initialCatId)
const [drawerProduct, setDrawerProduct] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
const [expandedSubs, setExpandedSubs] = useState(() => {
if (!initialCatId) return {}
@@ -125,18 +124,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
return (
<div className="product-picker">
<div className="category-tabs">
<div className="category-tabs__sticky">
<button
className="cat-tab cat-tab--viewall"
onClick={() => setViewAllOpen(true)}
title="Εμφάνιση όλων"
>
<CategoriesIcon width="20" height="20" />
</button>
</div>
<div className="category-tabs__scroll-wrap">
<div className="category-tabs__fade" />
<div className="category-tabs__scroll">
{topLevel.map(cat => {
const isActive = activeCat === cat.id

View File

@@ -2,6 +2,8 @@ import { useRef, useState } from 'react'
import useThemeStore from '../store/themeStore'
import useTableColourStore from '../store/tableColourStore'
const API_URL = import.meta.env.VITE_API_URL || ''
const STATUS_LABELS = {
free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ',
@@ -13,7 +15,555 @@ const STATUS_LABELS = {
const DRAG_THRESHOLD = 8
const HOLD_MS = 480
export default function TableCard({ table, order, isMine, flags = [], groupName = '', onClick, onLongPress }) {
// ─── Avatar helpers ───────────────────────────────────────────────────────────
const AVATAR_PALETTE = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a']
function avatarColor(name = '') {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
}
function WaiterAvatar({ waiter, size = 22, ring }) {
const displayName = waiter.nickname || waiter.full_name || waiter.username || '?'
const initials = displayName.trim().split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
const ringStyle = ring ? { boxShadow: `0 0 0 2px ${ring}` } : {}
if (waiter.avatar_url) {
return (
<img
src={API_URL + waiter.avatar_url}
alt={displayName}
style={{
width: size, height: size, borderRadius: '50%',
objectFit: 'cover', flexShrink: 0,
...ringStyle,
}}
/>
)
}
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(displayName),
color: 'white', fontSize: size * 0.4, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
...ringStyle,
}}>{initials}</div>
)
}
// Renders [icon] Name, [icon] Name inline. Falls back to icons + "X Waiters" if they don't fit
// (we approximate "don't fit" as > 2 waiters for the compact footer height).
function WaiterRow({ waiters, size = 22, cfg }) {
if (!waiters?.length) return null
const textColor = cfg.nameText
// ≤ 2 waiters: show icon + name pairs
if (waiters.length <= 2) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'nowrap', overflow: 'hidden', minWidth: 0 }}>
{waiters.map((w, i) => {
const name = w.nickname || w.full_name || w.username || '?'
return (
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, overflow: 'hidden' }}>
{i > 0 && <span style={{ color: textColor, opacity: 0.3, fontSize: 14, flexShrink: 0 }}>·</span>}
<WaiterAvatar waiter={w} size={size} />
<span style={{
fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.85,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{name}</span>
</div>
)
})}
</div>
)
}
// > 2 waiters: icons only + "X Waiters" label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={w.id} style={{ marginLeft: i === 0 ? 0 : -(size * 0.28) }}>
<WaiterAvatar waiter={w} size={size} ring={cfg.cardBg} />
</div>
))}
{waiters.length > 3 && (
<div style={{
marginLeft: -(size * 0.28), height: size, padding: '0 6px',
borderRadius: size, background: `${cfg.nameText}20`,
color: cfg.nameText, fontSize: 10, fontWeight: 700,
display: 'flex', alignItems: 'center',
}}>+{waiters.length - 3}</div>
)}
<span style={{ fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.7, marginLeft: 4 }}>
{waiters.length} σερβιτόροι
</span>
</div>
)
}
// ─── Status pill ──────────────────────────────────────────────────────────────
function StatusPill({ label, badgeBg, badgeText, small }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
height: small ? 18 : 20,
padding: small ? '0 6px' : '0 8px',
borderRadius: 4,
background: badgeBg,
color: badgeText,
fontSize: small ? 9 : 10,
fontWeight: 800,
letterSpacing: 0.4,
whiteSpace: 'nowrap',
}}>{label}</span>
)
}
// ─── Flag dot ─────────────────────────────────────────────────────────────────
function FlagDot({ flag, size = 22 }) {
const textColor = flag.text_color || '#ffffff'
return (
<div
title={flag.name}
style={{
width: size, height: size, borderRadius: '50%',
background: flag.color || '#6295F3',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.55,
flexShrink: 0,
color: textColor,
}}
>
{flag.emoji || '🏷️'}
</div>
)
}
// ─── Flag overflow row: show up to maxShow dots, then +N bubble ───────────────
function FlagDots({ flags, size, maxShow }) {
if (!flags.length) return null
const visible = flags.slice(0, maxShow)
const overflow = flags.length - maxShow
return (
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
{visible.map(f => <FlagDot key={f.id} flag={f} size={size} />)}
{overflow > 0 && (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'rgba(0,0,0,0.18)',
color: '#fff', fontSize: size * 0.44, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>+{overflow}</div>
)}
</div>
)
}
// ─── Flag chip (icon + label) ─────────────────────────────────────────────────
function FlagChip({ flag }) {
const textColor = flag.text_color || '#ffffff'
return (
<div
title={flag.name}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px',
borderRadius: 13,
background: flag.color || '#6295F3',
flexShrink: 0,
}}
>
<span style={{ fontSize: 13, lineHeight: 1 }}>{flag.emoji || '🏷️'}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: textColor, whiteSpace: 'nowrap' }}>
{flag.name}
</span>
</div>
)
}
// ─── Amount display ───────────────────────────────────────────────────────────
function Amount({ value, size = 22, color }) {
const s = Number(value || 0).toFixed(2)
const [whole, cents] = s.split('.')
const isNum = typeof size === 'number'
const centsSize = isNum ? size * 0.56 : `calc(${size} * 0.56)`
return (
<div style={{ lineHeight: 1, color: color || 'inherit' }}>
<span style={{ fontSize: size, fontWeight: 800, letterSpacing: -0.5 }}>{whole}</span>
<span style={{ fontSize: centsSize, fontWeight: 800, opacity: 0.8 }}>.{cents}</span>
</div>
)
}
// ─── Card variants ────────────────────────────────────────────────────────────
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, status bottom.
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
return (
<div style={{
width: '100%', aspectRatio: '1 / 1.05',
background: cfg.cardBg, borderRadius: 14,
position: 'relative', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
padding: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
{/* top strip: badges up to 2, then +N */}
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<FlagDots flags={flags} size={16} maxShow={2} />
</div>
{/* center: name */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
}}>
{table.label || `T${table.number}`}
</div>
{/* bottom strip: status */}
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
<span style={{
fontSize: 7, fontWeight: 800, letterSpacing: 0.3,
color: cfg.badgeText, textTransform: 'uppercase',
background: cfg.badgeBg, borderRadius: 3,
padding: '1px 4px', whiteSpace: 'nowrap',
}}>
{STATUS_LABELS[statusKey]}
</span>
</div>
</div>
)
}
// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right.
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
return (
<div style={{
width: '100%', height: 64,
background: cfg.cardBg, borderRadius: 14,
padding: '10px 12px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 10, overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
<div style={{
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
}}>
{table.label || `T${table.number}`}
</div>
<div style={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
}}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
{flags.length > 0 && (
<FlagDots flags={flags} size={18} maxShow={3} />
)}
</div>
</div>
)
}
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return (
<div style={{
width: '100%', minHeight: 116,
background: cfg.cardBg, borderRadius: 16,
padding: '12px 12px 12px',
display: 'flex', gap: 8, overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
}}>
{/* left column */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
}}>
{table.label || `T${table.number}`}
</span>
<div style={{ marginTop: 5 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
</div>
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
</div>
</div>
{/* right column: flags — show 2, then +N */}
{flags.length > 0 && (
<div style={{
display: 'flex', flexDirection: 'column-reverse',
gap: 4, alignItems: 'flex-end', justifyContent: 'flex-start',
}}>
<FlagDots flags={flags} size={26} maxShow={2} />
</div>
)}
</div>
)
}
// 4x1 — full width horizontal. Name + amount left-center, badges (up to 3 + +N) + status right.
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return (
<div style={{
width: '100%', height: 68,
background: cfg.cardBg, borderRadius: 14,
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
{/* name */}
<div style={{
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
}}>
{table.label || `T${table.number}`}
</div>
{/* separator dot */}
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
{/* amount */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
</div>
{/* flags up to 3 + +N */}
{flags.length > 0 && (
<FlagDots flags={flags} size={24} maxShow={3} />
)}
{/* status */}
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
</div>
)
}
// 4x2 — full width, tall. One main row: name+zone left, status center, amount+flags right. Flag chips below. Waiter footer.
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
const showWaiters = !isFree && waiterObjects.length > 0
return (
<div style={{
width: '100%',
background: cfg.cardBg, borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
display: 'flex', flexDirection: 'column',
}}>
{/* main body */}
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{/* left: name + zone */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: 800, fontSize: 'clamp(30px, 7vw, 44px)',
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
}}>
{table.label || `T${table.number}`}
</div>
{groupName && (
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
color: cfg.nameText, opacity: 0.6,
textTransform: 'uppercase', marginTop: 3,
}}>
{groupName}
</div>
)}
</div>
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
<div style={{ paddingTop: 4, flexShrink: 0 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
</div>
{/* right: amount — top-aligned */}
{showAmount && (
<div style={{ flexShrink: 0 }}>
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
</div>
)}
</div>
{/* flag chips row — right-aligned */}
{flags.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 6 }}>
{flags.slice(0, 4).map(f => <FlagChip key={f.id} flag={f} />)}
{flags.length > 4 && (
<div style={{
height: 26, padding: '0 9px', borderRadius: 13,
background: 'rgba(0,0,0,0.18)', color: '#fff',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center',
}}>+{flags.length - 4}</div>
)}
</div>
)}
</div>
{/* footer: waiters */}
<div style={{
borderTop: `1px solid ${cfg.nameText}22`,
padding: '10px 14px', minHeight: 40,
display: 'flex', alignItems: 'center',
}}>
{showWaiters
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}></span>
}
</div>
</div>
)
}
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: order items list. Footer: waiters.
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
const isFree = !order
const activeItems = order?.items?.filter(i => i.status === 'active') ?? []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const showWaiters = !isFree && waiterObjects.length > 0
return (
<div style={{
width: '100%',
background: cfg.cardBg, borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
display: 'flex', flexDirection: 'column',
}}>
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
{/* left column: name, zone, amount, status, flags */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
<div>
<div style={{
fontWeight: 800, fontSize: 'clamp(28px, 6vw, 40px)',
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
}}>
{table.label || `T${table.number}`}
</div>
{groupName && (
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
color: cfg.nameText, opacity: 0.6,
textTransform: 'uppercase', marginTop: 3,
}}>
{groupName}
</div>
)}
</div>
<div style={{ marginTop: 10 }}>
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
</div>
<div style={{ marginTop: 8 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
</div>
{flags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<FlagDots flags={flags} size={22} maxShow={3} />
</div>
)}
</div>
{/* divider */}
<div style={{ width: 1, background: `${cfg.nameText}20`, alignSelf: 'stretch', flexShrink: 0 }} />
{/* right column: order items */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
{isFree ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Ελεύθερο</span>
</div>
) : activeItems.length === 0 ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Κανένα είδος</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
{activeItems.slice(0, 7).map(item => (
<div key={item.id} style={{ display: 'flex', alignItems: 'baseline', gap: 5, overflow: 'hidden', minWidth: 0 }}>
<span style={{
fontSize: 11, fontWeight: 700, color: cfg.nameText,
background: `${cfg.nameText}18`, borderRadius: 3,
padding: '1px 5px', flexShrink: 0,
}}>{item.quantity}×</span>
<span style={{
fontSize: 12, fontWeight: 500, color: cfg.nameText,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
}}>{item.product?.name || `#${item.product_id}`}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: cfg.nameText, opacity: 0.7, flexShrink: 0 }}>
{(item.unit_price * item.quantity).toFixed(2)}
</span>
</div>
))}
{activeItems.length > 7 && (
<div style={{ fontSize: 11, color: cfg.nameText, opacity: 0.5, marginTop: 2 }}>
+{activeItems.length - 7} ακόμα
</div>
)}
</div>
)}
</div>
</div>
{/* footer: waiters */}
<div style={{
borderTop: `1px solid ${cfg.nameText}22`,
padding: '10px 14px', minHeight: 38,
display: 'flex', alignItems: 'center',
}}>
{showWaiters
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}></span>
}
</div>
</div>
)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export default function TableCard({
table,
order,
isMine,
flags = [],
groupName = '',
waiterObjects = [],
density = '2x2',
onClick,
onLongPress,
}) {
const holdTimer = useRef(null)
const startPos = useRef({ x: 0, y: 0 })
const didFire = useRef(false)
@@ -31,8 +581,6 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
const mode = dark ? 'dark' : 'light'
const cfg = colours[mode][statusKey]
const displayName = table.label || `T${table.number}`
function cancel() {
clearTimeout(holdTimer.current)
holdTimer.current = null
@@ -57,10 +605,7 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
}
function onTouchEnd() {
cancel()
setShowTip(false)
}
function onTouchEnd() { cancel(); setShowTip(false) }
function onMouseDown(e) {
startPos.current = { x: e.clientX, y: e.clientY }
@@ -85,11 +630,21 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
onClick?.()
}
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
const CardComponent = {
'1x1': Card1x1,
'2x1': Card2x1,
'2x2': Card2x2,
'4x1': Card4x1,
'4x2': Card4x2,
'4x3': Card4x3,
}[density] || Card2x2
return (
<div style={{ position: 'relative' }}>
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
<button
className="table-card-v2"
style={{ background: cfg.cardBg }}
style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
onClick={handleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
@@ -99,89 +654,16 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
>
{/* Top-left: table name + area */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '65%' }}>
<span style={{
fontSize: 'clamp(22px, 5.5vw, 36px)',
fontWeight: 800,
lineHeight: 1.05,
color: cfg.nameText,
letterSpacing: -0.5,
}}>
{displayName}
</span>
{groupName && (
<span style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: 0.8,
color: cfg.nameText + '80',
marginTop: 1,
textTransform: 'uppercase',
}}>
{groupName}
</span>
)}
</div>
{/* Bottom-left: status badge */}
<div style={{
position: 'absolute', bottom: 11, left: 11,
background: cfg.badgeBg,
borderRadius: 5,
padding: '2px 8px',
}}>
<span style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.5,
color: cfg.badgeText,
whiteSpace: 'nowrap',
}}>
{STATUS_LABELS[statusKey]}
</span>
</div>
{/* Bottom-right: flag circles, stacked, up to 3 visible */}
{flags.length > 0 && (
<div style={{
position: 'absolute', bottom: 8, right: 10,
display: 'flex', flexDirection: 'column-reverse', gap: 4,
}}>
{flags.slice(0, 3).map(f => (
<div key={f.id} style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
}}>
{f.emoji || '🏷️'}
</div>
))}
{flags.length > 3 && (
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: '#fff',
}}>
+{flags.length - 3}
</div>
)}
</div>
)}
<CardComponent {...cardProps} />
</button>
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
{showTip && flags.length > 0 && (
<div style={{
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 10, padding: '8px 12px', zIndex: 50,
boxShadow: '0 4px 16px var(--shadow)',
minWidth: 160,
pointerEvents: 'none',
minWidth: 160, pointerEvents: 'none',
}}>
{flags.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>

View File

@@ -168,6 +168,12 @@ export default function UserMenu() {
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
</button>
{/* ── Settings ──────────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
<span className="user-menu-item__icon"></span>
<span>Ρυθμίσεις</span>
</button>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>

View File

@@ -52,9 +52,8 @@ function NotificationBanner({ message, onAck }) {
export function NotificationProvider({ children }) {
const { token, user } = useAuthStore()
const [pendingMessages, setPendingMessages] = useState([]) // unacked
const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history)
const pollRef = useRef(null)
const [pendingMessages, setPendingMessages] = useState([])
const [recentMessages, setRecentMessages] = useState([])
const fetchUnread = useCallback(async () => {
if (!token || !user) return
@@ -72,14 +71,62 @@ export function NotificationProvider({ children }) {
} catch { }
}, [token, user?.id])
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
useEffect(() => {
if (!token || !user) return
fetchUnread()
fetchRecent()
pollRef.current = setInterval(fetchUnread, 2000)
return () => clearInterval(pollRef.current)
const id = setInterval(fetchUnread, 5000)
return () => clearInterval(id)
}, [token, user?.id])
// SSE message_sent events → add to pending without polling
useEffect(() => {
function onSSEEvent(e) {
const { type, data } = e.detail
if (type !== 'message_sent') return
if (!user) return
// Check if this message targets us (empty = broadcast)
const targets = data.target_waiter_ids || []
if (targets.length > 0 && !targets.includes(user.id)) return
const msg = {
id: data.id,
sender_id: data.sender_id,
sender_name: data.sender_name,
body: data.body,
table_ids: data.table_ids,
created_at: data.created_at,
acked_by: [],
}
setPendingMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev
return [msg, ...prev]
})
setRecentMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev
return [msg, ...prev].slice(0, 10)
})
}
window.addEventListener('sse-event', onSSEEvent)
return () => window.removeEventListener('sse-event', onSSEEvent)
}, [user?.id])
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
useEffect(() => {
function onSSEConnect() {
fetchUnread()
fetchRecent()
}
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
// through the backend-coming-back-online signal that SSEProvider dispatches
window.addEventListener('sse-reconnected', onSSEConnect)
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
}, [fetchUnread, fetchRecent])
async function ackMessage(messageId) {
try {
await client.post(`/api/messages/${messageId}/ack`)
@@ -91,7 +138,7 @@ export function NotificationProvider({ children }) {
const unreadCount = pendingMessages.length
return (
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}>
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
{children}
{/* Floating banner stack (max 3 visible) */}

View File

@@ -0,0 +1,189 @@
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
import useAuthStore from '../store/authStore'
import useConnectionStore from '../store/connectionStore'
import { useSSE } from '../hooks/useSSE'
import db from '../db/posdb'
import client from '../api/client'
import { flushOfflinePayments } from '../services/offlinePayments'
const SSEContext = createContext(null)
export function useSSEContext() {
return useContext(SSEContext)
}
const HEARTBEAT_INTERVAL = 30_000
export function SSEProvider({ children }) {
const { token } = useAuthStore()
const { setLost, setOnline } = useConnectionStore()
const sseAlive = useRef(false)
const heartbeatRef = useRef(null)
// Keep setLost/setOnline in refs so heartbeat/event closures are never stale
const setLostRef = useRef(setLost)
const setOnlineRef = useRef(setOnline)
useEffect(() => { setLostRef.current = setLost }, [setLost])
useEffect(() => { setOnlineRef.current = setOnline }, [setOnline])
// ── Snapshot helpers ─────────────────────────────────────────────────────────
const snapshotTables = useCallback(async () => {
try {
const res = await client.get('/api/tables/')
await db.tables.bulkPut(res.data)
} catch { /* offline — snapshot stays as-is */ }
}, [])
const snapshotOrders = useCallback(async () => {
try {
const res = await client.get('/api/orders/active')
const slimOrders = res.data
// Fetch full order details (with items) so emergency mode has them
const fullOrders = await Promise.all(
slimOrders.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)
)
)
await db.orders.bulkPut(fullOrders)
} catch { /* offline — snapshot stays as-is */ }
}, [])
const fullRefresh = useCallback(async () => {
await Promise.all([snapshotTables(), snapshotOrders()])
}, [snapshotTables, snapshotOrders])
// ── SSE event handler ────────────────────────────────────────────────────────
const handleEvent = useCallback(async (type, data) => {
// Dispatch for any UI component listening to window events
window.dispatchEvent(new CustomEvent('sse-event', { detail: { type, data } }))
// Incrementally update IndexedDB snapshot
switch (type) {
case 'order_updated':
case 'order_paid': {
// Try to fetch the full order to keep items in the snapshot
try {
const full = await client.get(`/api/orders/${data.order_id}`)
const o = full.data
await db.orders.put({
...o,
waiter_ids: o.waiters?.map(w => w.waiter_id) ?? [],
})
} catch {
// Fallback: update only the slim fields we know
const existing = await db.orders.get(data.order_id)
await db.orders.put({
...(existing || {}),
id: data.order_id,
table_id: data.table_id,
status: data.status,
waiter_ids: existing?.waiter_ids || [],
})
}
break
}
case 'order_closed': {
await db.orders.delete(data.order_id)
break
}
case 'table_list_changed': {
await snapshotTables()
break
}
default:
break
}
}, [snapshotTables])
// ── SSE connection lifecycle ─────────────────────────────────────────────────
const handleConnect = useCallback(async () => {
sseAlive.current = true
const wasEmergency = useConnectionStore.getState().status === 'emergency'
setOnlineRef.current()
window.dispatchEvent(new Event('sse-reconnected'))
if (wasEmergency) {
const result = await flushOfflinePayments()
if (result.duplicates > 0 || result.failed > 0) {
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
}
}
await fullRefresh()
}, [fullRefresh])
const handleDisconnect = useCallback(() => {
sseAlive.current = false
// Don't immediately setLost — heartbeat is the authoritative check
}, [])
const { reconnect } = useSSE({
token,
enabled: !!token,
onEvent: handleEvent,
onConnect: handleConnect,
onDisconnect: handleDisconnect,
})
// ── Heartbeat ────────────────────────────────────────────────────────────────
useEffect(() => {
if (!token) return
async function beat() {
try {
await client.get('/api/system/health')
const currentStatus = useConnectionStore.getState().status
if (currentStatus === 'lost' || currentStatus === 'emergency') {
if (currentStatus === 'emergency') {
const result = await flushOfflinePayments()
if (result.duplicates > 0 || result.failed > 0) {
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
}
}
setOnlineRef.current()
reconnect()
await fullRefresh()
}
} catch {
if (!sseAlive.current) {
setLostRef.current()
}
}
}
heartbeatRef.current = setInterval(beat, HEARTBEAT_INTERVAL)
return () => clearInterval(heartbeatRef.current)
// reconnect and fullRefresh are stable (useCallback with no changing deps)
}, [token, reconnect, fullRefresh])
// ── React to failed API requests (immediate detection) ───────────────────────
useEffect(() => {
function onBackendOffline() {
if (!sseAlive.current) {
setLostRef.current()
}
}
window.addEventListener('backend-offline', onBackendOffline)
return () => window.removeEventListener('backend-offline', onBackendOffline)
}, [])
// ── Initial snapshot on login ─────────────────────────────────────────────────
useEffect(() => {
if (token) fullRefresh()
}, [token, fullRefresh])
return (
<SSEContext.Provider value={{ reconnect, fullRefresh }}>
{children}
</SSEContext.Provider>
)
}

View File

@@ -0,0 +1,15 @@
import Dexie from 'dexie'
/**
* Local IndexedDB snapshot — written by SSE events and full GETs.
* Read-only in Emergency Mode when the server is unreachable.
*/
const db = new Dexie('pos_snapshot')
db.version(1).stores({
tables: 'id, group_id, is_active', // TableOut snapshots
orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots
offline_payments: '++localId, uuid, synced', // queued emergency payments
})
export default db

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } from 'react'
const BASE_URL = import.meta.env.VITE_API_URL || 'http://192.168.1.10:8000'
const INITIAL_RECONNECT_DELAY = 3000
const MAX_RECONNECT_DELAY = 30000
/**
* Opens an SSE connection to /api/sse/stream?token=<jwt>.
*
* Callbacks (onEvent, onConnect, onDisconnect) are stored in refs so they are
* always current without causing the EventSource to reconnect when they change.
*
* The connection is created/destroyed only when `token` or `enabled` changes.
*/
export function useSSE({ token, onEvent, onConnect, onDisconnect, enabled = true }) {
// Keep callbacks in refs so the EventSource closure always calls the latest version
const onEventRef = useRef(onEvent)
const onConnectRef = useRef(onConnect)
const onDisconnectRef = useRef(onDisconnect)
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
useEffect(() => { onConnectRef.current = onConnect }, [onConnect])
useEffect(() => { onDisconnectRef.current = onDisconnect }, [onDisconnect])
const esRef = useRef(null)
const reconnectTimer = useRef(null)
const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY)
const unmounted = useRef(false)
// Expose reconnect so SSEContext can trigger it after heartbeat recovery
const reconnectRef = useRef(null)
useEffect(() => {
if (!token || !enabled) return
unmounted.current = false
function connect() {
if (unmounted.current) return
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
const url = `${BASE_URL}/api/sse/stream?token=${encodeURIComponent(token)}`
const es = new EventSource(url)
esRef.current = es
es.onopen = () => {
reconnectDelay.current = INITIAL_RECONNECT_DELAY
onConnectRef.current?.()
}
es.onmessage = (e) => {
try {
const { type, data } = JSON.parse(e.data)
onEventRef.current?.(type, data)
} catch {
// malformed event — ignore
}
}
es.onerror = () => {
es.close()
esRef.current = null
onDisconnectRef.current?.()
if (unmounted.current) return
reconnectTimer.current = setTimeout(() => {
reconnectDelay.current = Math.min(
reconnectDelay.current * 1.5,
MAX_RECONNECT_DELAY
)
connect()
}, reconnectDelay.current)
}
}
reconnectRef.current = connect
connect()
return () => {
unmounted.current = true
clearTimeout(reconnectTimer.current)
esRef.current?.close()
esRef.current = null
}
}, [token, enabled])
// Stable reference — never changes, so heartbeat useEffect dep array stays stable
const reconnect = useCallback(() => {
clearTimeout(reconnectTimer.current)
reconnectDelay.current = INITIAL_RECONNECT_DELAY
reconnectRef.current?.()
}, [])
return { reconnect }
}

View File

@@ -211,70 +211,23 @@ html, body {
.text-input:focus { border-color: var(--accent); }
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
/* ── Filter Tabs ─────────────────────────────────────────── */
.filter-tabs {
/* ── Zone Tab Bar (replaces old filter-tabs) ─────────────── */
.zone-tab-bar {
display: flex;
gap: 8px;
padding: 12px 16px;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--bg);
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.filter-tab {
flex: 1;
padding: 10px;
border-radius: 8px;
border: none;
background: var(--bg2);
color: var(--muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.filter-tab--active { background: var(--accent); color: var(--accent-fg); }
.zone-tab-bar::-webkit-scrollbar { display: none; }
/* ── Table Grid ──────────────────────────────────────────── */
.table-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
align-content: start;
}
.table-card-v2 {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 12px 12px 48px;
width: 100%;
min-height: 116px;
border-radius: 16px;
border: none;
cursor: pointer;
text-align: left;
overflow: hidden;
transition: transform 0.12s;
box-shadow: 0 2px 10px var(--shadow);
}
/* ── Table Grid — density-driven via inline style ─────────── */
/* Cards use inline styles per density, grid columns come from JS */
.table-card-v2:active { transform: scale(0.96); }
/* ── FAB ─────────────────────────────────────────────────── */
.fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent);
color: var(--accent-fg);
font-size: 24px;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px var(--shadow);
}
/* ── Cart badge ──────────────────────────────────────────── */
.cart-badge {
position: absolute;
@@ -315,20 +268,10 @@ html, body {
align-items: stretch;
overflow: hidden;
}
.category-tabs__fade {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
background: linear-gradient(to right, var(--bg2) 40%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.category-tabs__scroll {
display: flex;
gap: 8px;
padding: 10px 12px 10px 36px;
padding: 10px 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;

View File

@@ -20,6 +20,10 @@ export default function AddItemsPage() {
const [printAck, setPrintAck] = useState(null)
const [cartOpen, setCartOpen] = useState(false)
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
const [viewAllOpen, setViewAllOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
async function load() {
@@ -310,31 +314,55 @@ export default function AddItemsPage() {
<header className="top-bar">
<button className="icon-btn" onClick={handleBack}></button>
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
{/* Cart icon with badge — opens side drawer */}
<button
className="icon-btn"
style={{ position: 'relative' }}
onClick={() => setCartOpen(true)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{cart.length > 0 && (
<span style={{
position: 'absolute', top: -2, right: -2,
minWidth: 18, height: 18, borderRadius: 9,
background: 'var(--accent)', color: 'var(--accent-fg)',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 4px',
}}>{cart.length}</span>
)}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* Search button */}
<button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
{/* Categories button */}
<button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
</button>
{/* Cart button with badge */}
<button
className="icon-btn"
style={{ position: 'relative' }}
onClick={() => setCartOpen(true)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{cart.length > 0 && (
<span style={{
position: 'absolute', top: -2, right: -2,
minWidth: 18, height: 18, borderRadius: 9,
background: 'var(--accent)', color: 'var(--accent-fg)',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 4px',
}}>{cart.length}</span>
)}
</button>
</div>
</header>
{/* Product picker takes all remaining space */}
{categories.length > 0 && (
<ProductPicker categories={categories} products={products} onAdd={addToCart} />
<ProductPicker
categories={categories}
products={products}
onAdd={addToCart}
viewAllOpen={viewAllOpen}
setViewAllOpen={setViewAllOpen}
/>
)}
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
@@ -382,17 +410,12 @@ export default function AddItemsPage() {
className="btn btn--primary btn--lg"
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
onClick={sendOrder}
disabled={cart.length === 0 || sending}
disabled={cart.length === 0 || sending || !!printAck?.allOk}
>
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
</button>
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
{printAck?.allOk && (
<div style={{ marginTop: 8, background: '#14532d', border: '1px solid #22c55e', borderRadius: 10, padding: '8px 14px', color: '#86efac', fontWeight: 600, fontSize: 13, textAlign: 'center' }}>
Εκτυπώθηκε επιτυχώς μεταφορά
</div>
)}
</div>
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
@@ -465,7 +488,7 @@ export default function AddItemsPage() {
className="btn btn--primary btn--lg"
style={{ width: '100%' }}
onClick={sendOrder}
disabled={cart.length === 0 || sending}
disabled={cart.length === 0 || sending || !!printAck?.allOk}
>
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
</button>
@@ -483,6 +506,46 @@ export default function AddItemsPage() {
initialState={editItem.drawerState}
/>
)}
{/* ── Search modal ─────────────────────────────────────────────────────── */}
{searchOpen && (
<SearchModal
products={products}
query={searchQuery}
setQuery={setSearchQuery}
onClose={() => setSearchOpen(false)}
onAdd={item => { addToCart(item); setSearchOpen(false) }}
/>
)}
{/* Full-screen success overlay — blocks all interaction while navigating */}
{printAck?.allOk && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.72)',
animation: 'fadeInOverlay 180ms ease',
}}>
<div style={{
background: '#14532d', border: '2px solid #22c55e',
borderRadius: 20, padding: '36px 48px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
animation: 'popIn 220ms cubic-bezier(0.34,1.56,0.64,1)',
}}>
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" stroke="#22c55e" strokeWidth="2"/>
<path d="M7 12.5l3.5 3.5 6.5-7" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#86efac', fontWeight: 700, fontSize: 18, letterSpacing: 0.3 }}>
Εκτυπώθηκε Επιτυχώς
</span>
</div>
<style>{`
@keyframes fadeInOverlay { from { opacity: 0 } to { opacity: 1 } }
@keyframes popIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
`}</style>
</div>
)}
</div>
)
}
@@ -638,3 +701,144 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
</div>
)
}
// ── Search Modal ──────────────────────────────────────────────────────────────
const API_URL = import.meta.env.VITE_API_URL || ''
function SearchModal({ products, query, setQuery, onClose, onAdd }) {
const [drawerProduct, setDrawerProduct] = useState(null)
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
const results = query.trim().length === 0
? []
: activeProducts.filter(p =>
p.name.toLowerCase().includes(query.trim().toLowerCase())
)
function openProduct(p) {
// Blur the input first so the keyboard dismisses, then open the drawer
document.activeElement?.blur()
setDrawerProduct(p)
}
// The modal is position:fixed anchored to bottom:0.
// When the soft keyboard opens on mobile the browser shrinks the visual
// viewport and fixed elements reposition automatically — the panel sits
// right on top of the keyboard without any JS measurement needed.
return (
<>
{/* Dim backdrop — tap to close */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }} />
{/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't
push the input off screen on short viewports */}
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0,
zIndex: 201,
background: 'var(--bg)',
borderTop: '1px solid var(--border)',
display: 'flex', flexDirection: 'column',
maxHeight: '60vh',
}}>
{/* Results scroll area — flex:1 so it takes space above the input */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{query.trim().length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
Πληκτρολογήστε για αναζήτηση
</p>
) : results.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
Δεν βρέθηκαν προϊόντα για «{query}»
</p>
) : results.map(p => {
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<button
key={p.id}
onClick={() => openProduct(p)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
width: '100%', padding: '10px 16px',
background: 'none', border: 'none', cursor: 'pointer',
borderBottom: '1px solid var(--border)',
textAlign: 'left',
}}
>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: 'var(--bg3)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{p.image_url
? <img src={`${API_URL}${p.image_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
{Number(p.base_price).toFixed(2)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)
})}
</div>
{/* Search input — pinned at the bottom of the panel, above the keyboard */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px 12px',
borderTop: '1px solid var(--border)',
flexShrink: 0,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Αναζήτηση προϊόντος…"
style={{
flex: 1, height: 44, background: 'var(--bg2)',
border: '1px solid var(--border)', borderRadius: 12,
padding: '0 12px', fontSize: 16, color: 'var(--text)',
fontFamily: 'inherit', outline: 'none',
}}
/>
<button
onClick={onClose}
style={{
background: 'var(--bg3)', border: 'none', borderRadius: '50%',
width: 36, height: 36, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--text)',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
</div>
</div>
{/* Product drawer — closes search modal when item is added */}
{drawerProduct && (
<OrderDrawer
product={drawerProduct}
isOpen
onClose={() => setDrawerProduct(null)}
onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }}
/>
)}
</>
)
}

View File

@@ -81,14 +81,19 @@ export default function LoginPage() {
const [waiters, setWaiters] = useState([])
const [loadingWaiters, setLoadingWaiters] = useState(true)
const [serverUnreachable, setServerUnreachable] = useState(false)
const [selectedWaiter, setSelectedWaiter] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
client.get('/api/auth/waiters')
.then(r => setWaiters(r.data))
.catch(() => setWaiters([]))
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
.catch(err => {
// No response = network error = server unreachable
if (!err.response) setServerUnreachable(true)
setWaiters([])
})
.finally(() => setLoadingWaiters(false))
}, [])
@@ -130,6 +135,30 @@ export default function LoginPage() {
<div style={{ maxWidth: 480, margin: '0 auto' }}>
{loadingWaiters ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση</p>
) : serverUnreachable ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔌</div>
<p style={{ fontSize: 17, fontWeight: 700, color: '#ef4444', marginBottom: 8 }}>
Δεν βρέθηκε ο Server
</p>
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 24 }}>
Δεν είναι δυνατή η σύνδεση με τον Manager.<br />
Δεν μπορεί να ξεκινήσει βάρδια χωρίς σύνδεση.
</p>
<button
className="btn btn--secondary"
onClick={() => {
setLoadingWaiters(true)
setServerUnreachable(false)
client.get('/api/auth/waiters')
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
.catch(err => { if (!err.response) setServerUnreachable(true) })
.finally(() => setLoadingWaiters(false))
}}
>
Επανάληψη
</button>
</div>
) : waiters.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
) : (

View File

@@ -0,0 +1,345 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useTableViewStore from '../store/tableViewStore'
import useThemeStore from '../store/themeStore'
// ─── Tab definitions (stub future tabs here) ──────────────────────────────────
const TABS = [
{ key: 'layout', label: 'Εμφάνιση' },
{ key: 'favorites', label: 'Αγαπημένα', disabled: true },
]
// ─── Density option data ──────────────────────────────────────────────────────
const DENSITY_OPTIONS = [
{
key: '1x1',
label: '1×1',
desc: '4 ανά σειρά — μόνο όνομα',
preview: <Grid4 />,
},
{
key: '2x1',
label: '2×1',
desc: '2 ανά σειρά — όνομα + κατάσταση',
preview: <Grid2H />,
},
{
key: '2x2',
label: '2×2',
desc: '2 ανά σειρά — συμπαγής κάρτα',
preview: <Grid2 />,
},
{
key: '4x1',
label: '4×1',
desc: '1 ανά σειρά — οριζόντια λίστα',
preview: <Grid1H />,
},
{
key: '4x2',
label: '4×2',
desc: '1 ανά σειρά — πλήρης κάρτα',
preview: <Grid1 />,
},
{
key: '4x3',
label: '4×3',
desc: '1 ανά σειρά — κάρτα με λίστα παραγγελίας',
preview: <Grid1Detail />,
},
]
// ─── Mini grid preview SVGs ───────────────────────────────────────────────────
function Grid4() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
{[0,1,2,3].map(i => (
<rect key={i} x={2 + i * 13} y="4" width="11" height="13" rx="2" fill="currentColor" opacity="0.9"/>
))}
{[0,1,2,3].map(i => (
<rect key={i+4} x={2 + i * 13} y="20" width="11" height="13" rx="2" fill="currentColor" opacity="0.55"/>
))}
{[0,1,2,3].map(i => (
<rect key={i+8} x={2 + i * 13} y="36" width="11" height="13" rx="2" fill="currentColor" opacity="0.25"/>
))}
</svg>
)
}
function Grid2H() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
{[0,1].map(i => (
<rect key={i} x={2 + i * 27} y="4" width="25" height="11" rx="2" fill="currentColor" opacity="0.9"/>
))}
{[0,1].map(i => (
<rect key={i+2} x={2 + i * 27} y="19" width="25" height="11" rx="2" fill="currentColor" opacity="0.55"/>
))}
{[0,1].map(i => (
<rect key={i+4} x={2 + i * 27} y="34" width="25" height="11" rx="2" fill="currentColor" opacity="0.25"/>
))}
</svg>
)
}
function Grid2() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="30" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
<rect x="30" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
function Grid1H() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="11" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="19" width="52" height="11" rx="2" fill="currentColor" opacity="0.55"/>
<rect x="2" y="34" width="52" height="11" rx="2" fill="currentColor" opacity="0.25"/>
</svg>
)
}
function Grid1() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="27" width="52" height="18" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
function Grid1Detail() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="20" rx="2" fill="currentColor" opacity="0.9"/>
{/* left section lines */}
<rect x="5" y="8" width="14" height="3" rx="1" fill="white" opacity="0.6"/>
<rect x="5" y="13" width="9" height="2" rx="1" fill="white" opacity="0.4"/>
<rect x="5" y="18" width="11" height="2" rx="1" fill="white" opacity="0.4"/>
{/* vertical divider */}
<rect x="22" y="7" width="1" height="14" rx="0.5" fill="white" opacity="0.3"/>
{/* right section lines */}
<rect x="25" y="8" width="24" height="2" rx="1" fill="white" opacity="0.5"/>
<rect x="25" y="12" width="20" height="2" rx="1" fill="white" opacity="0.35"/>
<rect x="25" y="16" width="22" height="2" rx="1" fill="white" opacity="0.25"/>
<rect x="2" y="29" width="52" height="15" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
// ─── Layout tab ───────────────────────────────────────────────────────────────
function LayoutTab() {
const { density, setDensity } = useTableViewStore()
const { dark, toggle } = useThemeStore()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, padding: '24px 16px' }}>
{/* Card density */}
<section>
<h2 style={sectionTitle}>Κάρτες τραπεζιών</h2>
<p style={sectionSub}>Επίλεξε πόσα στοιχεία εμφανίζονται σε κάθε κάρτα.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
{DENSITY_OPTIONS.map(opt => {
const active = density === opt.key
return (
<button
key={opt.key}
onClick={() => setDensity(opt.key)}
style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '14px 16px',
borderRadius: 14,
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 0.12s, background 0.12s',
}}
>
{/* Mini preview */}
<div style={{
width: 56, height: 48, flexShrink: 0,
color: active ? 'var(--accent)' : 'var(--muted)',
transition: 'color 0.12s',
}}>
{opt.preview}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 15, fontWeight: 700,
color: active ? 'var(--accent)' : 'var(--text)',
marginBottom: 2,
}}>
{opt.label}
</div>
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.4 }}>
{opt.desc}
</div>
</div>
{/* Check */}
{active && (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0, color: 'var(--accent)' }}>
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5"/>
<path d="M6.5 10l2.5 2.5 4.5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
)
})}
</div>
</section>
{/* Theme */}
<section>
<h2 style={sectionTitle}>Θέμα</h2>
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
{[
{ key: false, icon: '☀️', label: 'Φωτεινό' },
{ key: true, icon: '🌙', label: 'Σκοτεινό' },
].map(opt => {
const active = dark === opt.key
return (
<button
key={String(opt.key)}
onClick={() => { if (!active) toggle() }}
style={{
flex: 1,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
padding: '18px 12px',
borderRadius: 14,
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
cursor: active ? 'default' : 'pointer',
transition: 'border-color 0.12s, background 0.12s',
}}
>
<span style={{ fontSize: 28 }}>{opt.icon}</span>
<span style={{
fontSize: 14, fontWeight: 600,
color: active ? 'var(--accent)' : 'var(--muted)',
}}>{opt.label}</span>
</button>
)
})}
</div>
</section>
</div>
)
}
const sectionTitle = {
fontSize: 13, fontWeight: 700, color: 'var(--muted)',
letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4,
}
const sectionSub = {
fontSize: 14, color: 'var(--muted)', lineHeight: 1.5,
}
// ─── Favorites stub tab ───────────────────────────────────────────────────────
function FavoritesTab() {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 40, flex: 1 }}>
<span style={{ fontSize: 40 }}></span>
<p style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Σύντομα διαθέσιμο</p>
<p style={{ fontSize: 14, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.5 }}>
Τα αγαπημένα προϊόντα θα εμφανίζονται εδώ για γρήγορη παραγγελία.
</p>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('layout')
return (
<div className="page">
{/* Top bar */}
<header className="top-bar">
<button
onClick={() => navigate(-1)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text)', fontSize: 15, fontWeight: 600,
padding: '0 4px', minHeight: 44, borderRadius: 8,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15l-5-5 5-5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Πίσω
</button>
<span className="top-bar__title" style={{ textAlign: 'center' }}>Ρυθμίσεις</span>
{/* spacer to balance the back button */}
<div style={{ width: 72 }} />
</header>
{/* Tab strip */}
<div style={{
display: 'flex', gap: 0,
borderBottom: '1px solid var(--border)',
background: 'var(--bg2)',
padding: '0 16px',
}}>
{TABS.map(tab => (
<button
key={tab.key}
disabled={tab.disabled}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
style={{
padding: '14px 16px',
background: 'none', border: 'none',
borderBottom: activeTab === tab.key ? '2px solid var(--accent)' : '2px solid transparent',
color: tab.disabled
? 'var(--muted)'
: activeTab === tab.key
? 'var(--accent)'
: 'var(--text)',
fontSize: 14, fontWeight: 600,
cursor: tab.disabled ? 'not-allowed' : 'pointer',
opacity: tab.disabled ? 0.45 : 1,
marginBottom: -1, // overlap the border-bottom
whiteSpace: 'nowrap',
transition: 'color 0.12s',
}}
>
{tab.label}
{tab.disabled && (
<span style={{
marginLeft: 6, fontSize: 10, fontWeight: 700,
background: 'var(--bg3)', color: 'var(--muted)',
borderRadius: 4, padding: '1px 5px',
verticalAlign: 'middle',
}}>σύντομα</span>
)}
</button>
))}
</div>
{/* Tab body */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
{activeTab === 'layout' && <LayoutTab />}
{activeTab === 'favorites' && <FavoritesTab />}
</div>
</div>
)
}

View File

@@ -1,22 +1,34 @@
import { useEffect, useRef, useState } from 'react'
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'
const FILTERS = ['all', 'mine', 'free']
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
// ─── Notification history drawer ─────────────────────────────────────────────
// ─── Icons ────────────────────────────────────────────────────────────────────
function NotificationDrawer({ messages, onClose, onAck }) {
function FilterIcon({ size = 20 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
)
}
// ─── Notification drawer ──────────────────────────────────────────────────────
function NotificationDrawer({ messages, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
@@ -37,9 +49,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
<div style={{ flex: 1, minWidth: 0 }}>
{msg.sender_name && (
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
{msg.sender_name}
</div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
)}
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
{tableIds.length > 0 && (
@@ -59,7 +69,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
)
}
// ─── Table quick-view + actions popup (long-press) ────────────────────────────
// ─── Table quick-view modal (long press) ──────────────────────────────────────
const QUICK_ACTIONS = [
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
@@ -77,25 +87,18 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
const due = Math.max(0, total - paid)
const statusLabel = {
open: 'Ανοιχτό',
partially_paid: 'Μερικώς πληρωμένο',
paid: 'Πληρωμένο',
open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
}[order?.status] || 'Ελεύθερο'
return (
<div className="modal-overlay" onClick={onClose}>
{/* Status overview card */}
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
<div style={{
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
padding: '16px 20px', borderBottom: '1px solid var(--border)',
}}>
<div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
<div className="modal-handle" style={{ marginBottom: 12 }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
</div>
{order ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
@@ -116,7 +119,6 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
) : (
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
)}
{flags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{flags.map(f => (
@@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
))}
</div>
)}
<button
className="btn btn--primary"
style={{ width: '100%', marginTop: 14 }}
onClick={() => { onClose(); onNavigate() }}
>
<button className="btn btn--primary" style={{ width: '100%', marginTop: 14 }} onClick={() => { onClose(); onNavigate() }}>
Άνοιγμα τραπεζιού
</button>
</div>
{/* Quick actions card */}
<div style={{
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
padding: '8px 20px 24px',
borderTop: '2px solid var(--border)',
}}>
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
ACTIONS
</p>
<div style={{ background: 'var(--bg2)', borderRadius: '0 0 16px 16px', padding: '8px 20px 24px', borderTop: '2px solid var(--border)' }}>
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>ACTIONS</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{QUICK_ACTIONS.map((a, i) => {
const disabled = !order && a.key !== 'flags'
return (
<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',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}
>
<span style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: a.color,
}}>
<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',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}>
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: a.iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', color: a.color }}>
<a.Icon width="18" height="18" />
</span>
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
@@ -187,27 +166,225 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
)
}
// ─── 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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<div className="modal-handle" />
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🚨</div>
<p style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ</p>
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>Τραπέζι: <strong>{table.label || `T${table.number}`}</strong></p>
</div>
<div style={{ background: 'var(--bg3)', borderRadius: 12, padding: '12px 16px', marginBottom: 20 }}>
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>Ενεργά αντικείμενα:</p>
{activeItems.length === 0
? <p style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>Δεν υπάρχουν δεδομένα (offline snapshot)</p>
: activeItems.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, marginBottom: 4 }}>
<span style={{ color: 'var(--text)' }}>{item.product?.name || `#${item.product_id}`} ×{item.quantity}</span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} </span>
</div>
))
}
<div style={{ borderTop: '1px solid var(--border)', marginTop: 10, paddingTop: 10, display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 16 }}>
<span>Σύνολο</span>
<span style={{ color: '#ef4444' }}>{total.toFixed(2)} </span>
</div>
</div>
{total === 0
? <p style={{ fontSize: 13, color: '#ef4444', marginBottom: 16, lineHeight: 1.5, fontWeight: 600 }}>
Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.
</p>
: <p style={{ fontSize: 12, color: '#f59e0b', marginBottom: 16, lineHeight: 1.5 }}>
Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.
</p>
}
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Ακύρωση</button>
<button
style={{ flex: 1, height: 44, borderRadius: 12, border: 'none', background: total === 0 ? '#64748b' : '#dc2626', color: '#fff', fontSize: 15, fontWeight: 700, cursor: (paying || total === 0) ? 'not-allowed' : 'pointer', opacity: (paying || total === 0) ? 0.5 : 1 }}
onClick={handlePay} disabled={paying || total === 0}
>
{paying ? '⟳ Καταχώρηση…' : '✓ Πληρωμή'}
</button>
</div>
</div>
</div>
)
}
// ─── 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 (
<div className="modal-overlay" onClick={onClose} style={{ alignItems: 'flex-end' }}>
<div
className="modal-sheet"
onClick={e => e.stopPropagation()}
style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }}
>
<div className="modal-handle" />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)' }}>Φίλτρα</span>
{hasActiveFilters && (
<button
onClick={() => { clearFilters(); onClose() }}
style={{ fontSize: 13, fontWeight: 600, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px' }}
>
Καθαρισμός
</button>
)}
</div>
{/* Owner: ALL | MINE */}
<div>
<p style={sectionLabel}>Ανάθεση</p>
<div style={segmentedWrap}>
{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => (
<button key={key} onClick={() => setOwnerFilter(key)} style={segBtn(ownerFilter === key)}>{lbl}</button>
))}
</div>
</div>
{/* Status: ALL | FREE | OPEN | PAID */}
<div>
<p style={sectionLabel}>Κατάσταση</p>
<div style={{ ...segmentedWrap, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => (
<button key={key} onClick={() => setStatusFilter(key)} style={{ ...segBtn(statusFilter === key), borderRadius: 10 }}>{lbl}</button>
))}
</div>
</div>
{/* Zones: multi-select, one segmented container per zone */}
{groups.length > 0 && (
<div>
<p style={sectionLabel}>Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{groups.map(g => {
const active = zoneFilter.includes(g.id)
return (
<div key={g.id} style={segmentedWrap}>
<button
onClick={() => toggleZone(g.id)}
style={{
...segBtn(active),
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
}}
>
{g.color && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: active ? 'currentColor' : g.color,
flexShrink: 0, opacity: active ? 0.9 : 1,
}} />
)}
{g.name}
</button>
</div>
)
})}
</div>
</div>
)}
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Εντάξει</button>
</div>
</div>
)
}
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 [filter, setFilter] = useState('all')
const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup
const [offline, setOffline] = useState(false)
const [zoneOpen, setZoneOpen] = useState(false)
const [selectedZones, setSelectedZones] = useState(new Set())
const [showNotifs, setShowNotifs] = useState(false)
const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
const zoneRef = useRef(null)
const navigate = useNavigate()
const [showFilters, setShowFilters] = useState(false)
const [quickModal, setQuickModal] = useState(null)
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {}
// 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)
@@ -215,28 +392,37 @@ export default function TableListPage() {
}, [])
useEffect(() => {
function onClick(e) {
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
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] = await Promise.all([
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)
setOrders(ordersRes.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)
@@ -245,6 +431,48 @@ export default function TableListPage() {
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 => {
@@ -252,36 +480,88 @@ export default function TableListPage() {
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 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)
}
function isMyOrder(order) {
if (!order || !user) return false
return order.waiter_ids?.includes(user.id)
}
// ── Filtering logic ────────────────────────────────────────────────────────
// Zones visible in top bar = those allowed by zoneFilter (or all if empty)
const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
function toggleZone(id) {
setSelectedZones(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
// 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)
if (filter === 'free' && order) return false
if (filter === 'mine' && !isMyOrder(order)) return false
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
// 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
})
const zoneActive = selectedZones.size > 0
// ── 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 to table then trigger action via URL param so TableDetailPage can handle it
navigate(`/tables/${tableId}?action=${actionKey}`)
}
@@ -299,15 +579,14 @@ export default function TableListPage() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
</svg>
{(unreadCount || 0) > 0 && (
<span style={{
position: 'absolute', top: 6, right: 6,
background: '#ef4444', color: 'white',
fontSize: 10, fontWeight: 700,
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
@@ -319,109 +598,135 @@ export default function TableListPage() {
<UserMenu />
</header>
{offline && <ConnectionBanner />}
{isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
<div className="filter-tabs">
{FILTERS.map(f => (
<button key={f} className={`filter-tab ${filter === f ? 'filter-tab--active' : ''}`} onClick={() => setFilter(f)}>
{FILTER_LABELS[f]}
</button>
{/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
background: 'var(--bg)',
borderBottom: '1px solid var(--border)',
overflowX: 'auto', scrollbarWidth: 'none',
}}>
{/* ALL tab */}
<ZoneTab
label="Όλα"
active={effectiveZoneTab === 'all'}
onClick={() => setActiveZoneTab('all')}
/>
{/* Per-zone tabs */}
{visibleGroups.map(g => (
<ZoneTab
key={g.id}
label={g.name}
color={g.color}
active={effectiveZoneTab === g.id}
onClick={() => setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)}
/>
))}
<div ref={zoneRef} style={{ position: 'relative' }}>
<button
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
onClick={() => setZoneOpen(o => !o)}
>
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
</button>
{zoneOpen && (
<div style={{
position: 'absolute', top: '110%', right: 0, zIndex: 100,
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
}}>
<button
onClick={() => setSelectedZones(new Set())}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Όλες οι ζώνες
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => toggleZone(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
{g.name}
</button>
))}
{tables.some(t => !t.group_id) && (
<button
onClick={() => toggleZone('none')}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Χωρίς ζώνη
</button>
)}
</div>
)}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
<div className="table-grid">
{/* ── Table grid ───────────────────────────────────────────────────────── */}
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}
onTouchStart={onPullTouchStart}
onTouchMove={onPullTouchMove}
onTouchEnd={onPullTouchEnd}
>
{/* Pull-to-refresh indicator */}
{(pulling || refreshing) && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: Math.min(pullY, PULL_THRESHOLD),
color: 'var(--muted)', fontSize: 13, fontWeight: 600,
overflow: 'hidden', transition: pulling ? 'none' : 'height 0.2s',
}}>
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: density === '1x1' ? 8 : 10,
padding: '12px 12px 88px',
alignContent: 'start',
}}>
{filtered.map(t => {
const order = getOrder(t.id)
const tableFlags = tableFlagsMap[t.id] || []
const grp = groups.find(g => g.id === t.group_id)
// Free tables go straight to the item picker; occupied tables go to detail
const destination = order
? `/tables/${t.id}`
: `/tables/${t.id}/add?new=1`
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 (
<TableCard
key={t.id}
table={t}
order={order}
order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
isMine={isMyOrder(order)}
flags={tableFlags}
groupName={grp?.name || ''}
onClick={() => navigate(destination)}
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
waiterObjects={orderWaiters}
density={density}
onClick={handleClick}
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
/>
)
})}
</div>
<button className="fab" onClick={load} title="Ανανέωση"></button>
</div>
{/* ── Filter FAB ───────────────────────────────────────────────────────── */}
<button
ref={filterBtnRef}
onClick={() => setShowFilters(true)}
style={{
position: 'fixed', bottom: 24, right: 24,
width: 52, height: 52, borderRadius: '50%', border: 'none',
background: hasActiveFilters ? '#ea6c00' : '#f97316',
color: '#fff',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 16px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.2)',
zIndex: 40,
transition: 'background 0.12s',
}}
>
<FilterIcon size={20} />
{hasActiveFilters && (
<span style={{
position: 'absolute', top: 0, right: 0,
background: '#ef4444', color: '#fff',
fontSize: 9, fontWeight: 800,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{(ownerFilter !== 'all' ? 1 : 0) + (statusFilter !== 'all' ? 1 : 0) + (zoneFilter.length > 0 ? 1 : 0)}
</span>
)}
</button>
{/* ── Modals ────────────────────────────────────────────────────────────── */}
{showNotifs && (
<NotificationDrawer
messages={recentMessages || []}
onClose={() => setShowNotifs(false)}
onAck={ackMessage}
/>
<NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
)}
{showFilters && (
<FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
)}
{quickModal && (
@@ -434,6 +739,43 @@ export default function TableListPage() {
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
/>
)}
{emergencyPayModal && (
<EmergencyPayModal
table={emergencyPayModal.table}
order={emergencyPayModal.order}
onClose={() => setEmergencyPayModal(null)}
onPay={handleEmergencyPay}
/>
)}
</div>
)
}
// ─── Zone tab pill ────────────────────────────────────────────────────────────
function ZoneTab({ label, color, active, onClick }) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 20, border: 'none',
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
fontWeight: 600, fontSize: 13,
background: active ? 'var(--accent)' : 'var(--bg3)',
color: active ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.12s, color 0.12s',
}}
>
{color && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: color, flexShrink: 0,
opacity: active ? 1 : 0.7,
}} />
)}
{label}
</button>
)
}

View File

@@ -0,0 +1,61 @@
import db from '../db/posdb'
import client from '../api/client'
/**
* Queue an emergency payment locally.
* Called in Emergency Mode when the server is unreachable.
*/
export async function queueOfflinePayment({ orderId, itemIds, paymentMethod }) {
const uuid = crypto.randomUUID()
await db.offline_payments.add({
uuid,
orderId,
itemIds,
paymentMethod,
offlineAt: new Date().toISOString(),
synced: 0,
isDuplicate: 0,
})
return uuid
}
/**
* Flush all unsynced offline payments to the server.
* Called when the server comes back online.
* Returns a summary of { synced, duplicates, failed }.
*/
export async function flushOfflinePayments() {
// Boolean is not a valid IndexedDB key — load all and filter in JS
const all = await db.offline_payments.toArray()
const pending = all.filter(p => !p.synced)
const results = { synced: 0, duplicates: 0, failed: 0 }
for (const payment of pending) {
try {
const res = await client.post(`/api/orders/${payment.orderId}/pay-offline`, {
uuid: payment.uuid,
item_ids: payment.itemIds,
payment_method: payment.paymentMethod,
offline_at: payment.offlineAt,
})
const isDuplicate = res.data.is_duplicate
await db.offline_payments.update(payment.localId, {
synced: 1,
isDuplicate: isDuplicate ? 1 : 0,
})
isDuplicate ? results.duplicates++ : results.synced++
} catch {
results.failed++
}
}
return results
}
/**
* Count unsynced pending payments (to show badge / warning).
*/
export async function pendingPaymentCount() {
const all = await db.offline_payments.toArray()
return all.filter(p => !p.synced).length
}

View File

@@ -0,0 +1,33 @@
import { create } from 'zustand'
/**
* Tracks the live connection state and emergency mode flag.
*
* States:
* 'online' — server reachable, SSE connected, normal operation
* 'lost' — server unreachable, modal shown (Wait / Emergency)
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
*/
const useConnectionStore = create((set, get) => ({
status: 'online', // 'online' | 'lost' | 'emergency'
lostAt: null, // Date when connection was lost
setLost: () => {
if (get().status === 'online') {
set({ status: 'lost', lostAt: new Date() })
}
},
setOnline: () => set({ status: 'online', lostAt: null }),
enterEmergency: () => set({ status: 'emergency' }),
// Called when server comes back while in emergency mode — triggers sync then go online
exitEmergency: () => set({ status: 'online', lostAt: null }),
isOnline: () => get().status === 'online',
isLost: () => get().status === 'lost',
isEmergency: () => get().status === 'emergency',
}))
export default useConnectionStore

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3'
// ownerFilter: 'all' | 'mine'
// statusFilter: 'all' | 'free' | 'open' | 'paid'
// zoneFilter: Set of zone IDs (serialized as array in localStorage)
// activeZoneTab: zone id string or 'all'
const useTableViewStore = create(
persist(
(set, get) => ({
density: '2x2',
ownerFilter: 'all',
statusFilter: 'all',
zoneFilter: [], // array of zone ids (serialized fine in JSON)
activeZoneTab: 'all',
setDensity: (density) => set({ density }),
setOwnerFilter: (ownerFilter) => set({ ownerFilter }),
setStatusFilter: (statusFilter) => set({ statusFilter }),
setZoneFilter: (zoneFilter) => set({ zoneFilter }),
setActiveZoneTab: (activeZoneTab) => set({ activeZoneTab }),
clearFilters: () => set({
ownerFilter: 'all',
statusFilter: 'all',
zoneFilter: [],
activeZoneTab: 'all',
}),
}),
{
name: 'table-view-prefs',
// future: could sync to backend here
}
)
)
export default useTableViewStore