Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.8icf0qrbd5"
|
||||
"revision": "0.jqv9du572qo"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
7
waiter_pwa/package-lock.json
generated
7
waiter_pwa/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
@@ -2940,6 +2941,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dexie": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
|
||||
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
100
waiter_pwa/src/components/ConnectionLostModal.jsx
Normal file
100
waiter_pwa/src/components/ConnectionLostModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
waiter_pwa/src/components/EmergencyBar.jsx
Normal file
40
waiter_pwa/src/components/EmergencyBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
189
waiter_pwa/src/context/SSEContext.jsx
Normal file
189
waiter_pwa/src/context/SSEContext.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
waiter_pwa/src/db/posdb.js
Normal file
15
waiter_pwa/src/db/posdb.js
Normal 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
|
||||
94
waiter_pwa/src/hooks/useSSE.js
Normal file
94
waiter_pwa/src/hooks/useSSE.js
Normal 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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
345
waiter_pwa/src/pages/SettingsPage.jsx
Normal file
345
waiter_pwa/src/pages/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
61
waiter_pwa/src/services/offlinePayments.js
Normal file
61
waiter_pwa/src/services/offlinePayments.js
Normal 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
|
||||
}
|
||||
33
waiter_pwa/src/store/connectionStore.js
Normal file
33
waiter_pwa/src/store/connectionStore.js
Normal 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
|
||||
39
waiter_pwa/src/store/tableViewStore.js
Normal file
39
waiter_pwa/src/store/tableViewStore.js
Normal 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
|
||||
@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: 'all',
|
||||
allowedHosts: ['all','pos-waiter.bonamin.gr'],
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
Reference in New Issue
Block a user