feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
waiter_pwa/package-lock.json
generated
27
waiter_pwa/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "waiter_pwa",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
@@ -2250,6 +2251,32 @@
|
||||
"string.prototype.matchall": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz",
|
||||
"integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz",
|
||||
"integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import useAuthStore from './store/authStore'
|
||||
import useShiftStore from './store/shiftStore'
|
||||
import useThemeStore from './store/themeStore'
|
||||
@@ -309,10 +310,21 @@ function ColourLoader() {
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Login guard — redirect to /tables if already authenticated ───────────────
|
||||
|
||||
function LoginGuard({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
if (token) return <Navigate to="/tables" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeApplier />
|
||||
<ColourLoader />
|
||||
@@ -322,7 +334,7 @@ export default function App() {
|
||||
<NotificationProvider>
|
||||
<ConnectionLostModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
@@ -335,5 +347,6 @@ export default function App() {
|
||||
</NotificationProvider>
|
||||
</SSEProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
const RETRY_INTERVAL = 10_000
|
||||
|
||||
export default function ConnectionLostModal() {
|
||||
const { status, setOnline, enterEmergency } = useConnectionStore()
|
||||
@@ -11,13 +11,13 @@ export default function ConnectionLostModal() {
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const retryRef = useRef(null)
|
||||
|
||||
const isVisible = status === 'lost'
|
||||
const isReconnecting = status === 'reconnecting'
|
||||
const isLost = status === 'lost'
|
||||
|
||||
async function tryReconnect() {
|
||||
setRetrying(true)
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
// Server is back
|
||||
setOnline()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
@@ -28,18 +28,53 @@ export default function ConnectionLostModal() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry every 10s while modal is open
|
||||
// Auto-retry every 10s while the full "lost" modal is open
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
clearInterval(retryRef.current)
|
||||
return
|
||||
}
|
||||
if (!isLost) { clearInterval(retryRef.current); return }
|
||||
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
|
||||
return () => clearInterval(retryRef.current)
|
||||
}, [isVisible])
|
||||
}, [isLost])
|
||||
|
||||
if (!isVisible) return null
|
||||
if (!isReconnecting && !isLost) return null
|
||||
|
||||
// ── Grace-period spinner ───────────────────────────────────────────────────
|
||||
if (isReconnecting) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1e293b',
|
||||
border: '2px solid #334155',
|
||||
borderRadius: 20,
|
||||
padding: '32px 28px',
|
||||
maxWidth: 340, width: '100%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Spinning ring */}
|
||||
<div style={{
|
||||
width: 52, height: 52, margin: '0 auto 20px',
|
||||
border: '4px solid #334155',
|
||||
borderTopColor: 'var(--accent, #f97316)',
|
||||
borderRadius: '50%',
|
||||
animation: 'gate-spin 0.8s linear infinite',
|
||||
}} />
|
||||
<p style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>
|
||||
Επανασύνδεση…
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: '#64748b', lineHeight: 1.6 }}>
|
||||
Προσπαθώ να φτάσω στον server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Full "lost" modal ──────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
@@ -58,33 +93,23 @@ export default function ConnectionLostModal() {
|
||||
}}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
|
||||
<p style={{
|
||||
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<p style={{ fontSize: 20, fontWeight: 700, color: '#f1f5f9', marginBottom: 10 }}>
|
||||
Χάθηκε η σύνδεση με τον Manager
|
||||
</p>
|
||||
|
||||
<p style={{
|
||||
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
|
||||
marginBottom: 28,
|
||||
}}>
|
||||
<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',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={enterEmergency}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 48, borderRadius: 12, border: 'none',
|
||||
flex: 1, height: 48, borderRadius: 12, border: 'none',
|
||||
background: '#dc2626', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
EMERGENCY MODE
|
||||
|
||||
@@ -281,7 +281,6 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
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={{
|
||||
@@ -302,8 +301,9 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
<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} />}
|
||||
{/* Always reserve amount height — invisible when free */}
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,6 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
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={{
|
||||
@@ -345,9 +344,9 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
{/* 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} />}
|
||||
{/* amount — always reserve space, invisible when free */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
|
||||
{/* flags up to 3 + +N */}
|
||||
@@ -365,7 +364,6 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
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 (
|
||||
@@ -404,12 +402,10 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
<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>
|
||||
)}
|
||||
{/* right: amount — always reserve space, invisible when free */}
|
||||
<div style={{ flexShrink: 0, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* flag chips row — right-aligned */}
|
||||
@@ -479,8 +475,8 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
|
||||
<div style={{ marginTop: 10, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
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'
|
||||
import { invalidateProductCache } from '../hooks/useProductCache'
|
||||
|
||||
const SSEContext = createContext(null)
|
||||
|
||||
@@ -17,6 +19,7 @@ const HEARTBEAT_INTERVAL = 30_000
|
||||
export function SSEProvider({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
const { setLost, setOnline } = useConnectionStore()
|
||||
const queryClient = useQueryClient()
|
||||
const sseAlive = useRef(false)
|
||||
const heartbeatRef = useRef(null)
|
||||
|
||||
@@ -97,10 +100,14 @@ export function SSEProvider({ children }) {
|
||||
await snapshotTables()
|
||||
break
|
||||
}
|
||||
case 'products_changed': {
|
||||
invalidateProductCache(queryClient)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [snapshotTables])
|
||||
}, [snapshotTables, queryClient])
|
||||
|
||||
// ── SSE connection lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
@@ -175,6 +182,32 @@ export function SSEProvider({ children }) {
|
||||
return () => window.removeEventListener('backend-offline', onBackendOffline)
|
||||
}, [])
|
||||
|
||||
// ── Wake-up handshake — fires when tab/app returns from background ────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
async function onVisible() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
const currentStatus = useConnectionStore.getState().status
|
||||
if (currentStatus === 'lost' || currentStatus === 'emergency' || currentStatus === 'reconnecting') {
|
||||
setOnlineRef.current()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
} else if (!sseAlive.current) {
|
||||
// SSE dropped silently while sleeping — re-establish quietly
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
}
|
||||
} catch {
|
||||
if (!sseAlive.current) setLostRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [token, reconnect, fullRefresh])
|
||||
|
||||
// ── Initial snapshot on login ─────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,9 +7,17 @@ import Dexie from 'dexie'
|
||||
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
|
||||
tables: 'id, group_id, is_active',
|
||||
orders: 'id, table_id, status',
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
})
|
||||
|
||||
db.version(2).stores({
|
||||
tables: 'id, group_id, is_active',
|
||||
orders: 'id, table_id, status',
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
69
waiter_pwa/src/hooks/useProductCache.js
Normal file
69
waiter_pwa/src/hooks/useProductCache.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
|
||||
export const PRODUCTS_KEY = ['products']
|
||||
export const CATEGORIES_KEY = ['categories']
|
||||
|
||||
async function fetchAndCacheProducts() {
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
client.get('/api/products/'),
|
||||
client.get('/api/products/categories'),
|
||||
])
|
||||
const products = prodRes.data
|
||||
const categories = catRes.data
|
||||
|
||||
// Write to IndexedDB in the background — don't await so UI isn't blocked
|
||||
db.products.bulkPut(products).catch(() => {})
|
||||
db.categories.bulkPut(categories).catch(() => {})
|
||||
|
||||
return { products, categories }
|
||||
}
|
||||
|
||||
async function loadFromCache() {
|
||||
const [products, categories] = await Promise.all([
|
||||
db.products.toArray(),
|
||||
db.categories.toArray(),
|
||||
])
|
||||
if (products.length === 0 && categories.length === 0) return null
|
||||
return { products, categories }
|
||||
}
|
||||
|
||||
export function useProductCache() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: PRODUCTS_KEY,
|
||||
queryFn: fetchAndCacheProducts,
|
||||
// Serve stale data instantly — products don't change every second
|
||||
staleTime: 5 * 60 * 1000, // 5 min before background re-fetch
|
||||
gcTime: 60 * 60 * 1000, // keep in memory for 1 hour
|
||||
placeholderData: undefined,
|
||||
})
|
||||
|
||||
// On mount, if the query has no data yet, seed it from IndexedDB immediately
|
||||
// so the UI renders without waiting for the network round-trip
|
||||
useEffect(() => {
|
||||
const cached = queryClient.getQueryData(PRODUCTS_KEY)
|
||||
if (cached) return
|
||||
|
||||
loadFromCache().then(idbData => {
|
||||
if (idbData) {
|
||||
// Set as placeholder — React Query will still fetch in background
|
||||
queryClient.setQueryData(PRODUCTS_KEY, idbData)
|
||||
}
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
return {
|
||||
products: query.data?.products ?? [],
|
||||
categories: query.data?.categories ?? [],
|
||||
isLoading: query.isLoading && !query.data,
|
||||
}
|
||||
}
|
||||
|
||||
// Call this from SSEContext when products_changed arrives
|
||||
export function invalidateProductCache(queryClient) {
|
||||
queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
|
||||
}
|
||||
@@ -667,7 +667,14 @@ html, body {
|
||||
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
|
||||
.user-menu-item--disabled:hover { background: transparent; }
|
||||
.user-menu-item--danger { color: var(--danger); }
|
||||
.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
|
||||
.user-menu-item__icon {
|
||||
font-size: 17px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import ProductPicker from '../components/ProductPicker'
|
||||
import OrderDrawer from '../components/OrderDrawer'
|
||||
import client from '../api/client'
|
||||
import { useProductCache } from '../hooks/useProductCache'
|
||||
|
||||
export default function AddItemsPage() {
|
||||
const { tableId } = useParams()
|
||||
@@ -10,8 +11,8 @@ export default function AddItemsPage() {
|
||||
const isNewTable = searchParams.get('new') === '1'
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [categories, setCategories] = useState([])
|
||||
const [products, setProducts] = useState([])
|
||||
const { products, categories } = useProductCache()
|
||||
|
||||
const [cart, setCart] = useState([])
|
||||
const [orderId, setOrderId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
@@ -24,16 +25,9 @@ export default function AddItemsPage() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [catRes, prodRes, statusRes] = await Promise.all([
|
||||
client.get('/api/products/categories'),
|
||||
client.get('/api/products/'),
|
||||
client.get(`/api/tables/${tableId}/status`),
|
||||
])
|
||||
setCategories(catRes.data)
|
||||
setProducts(prodRes.data)
|
||||
const statusRes = await client.get(`/api/tables/${tableId}/status`)
|
||||
setOrderId(statusRes.data.active_order_id)
|
||||
|
||||
// Pre-populate cart from "order again" if present
|
||||
|
||||
@@ -762,9 +762,10 @@ function ZoneTab({ label, color, active, onClick }) {
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 12px', borderRadius: 20, border: 'none',
|
||||
padding: '8px 16px', borderRadius: 20,
|
||||
border: '2px solid transparent',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
fontWeight: 600, fontSize: 13,
|
||||
fontWeight: 600, fontSize: 14,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
|
||||
@@ -4,26 +4,50 @@ 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
|
||||
* 'online' — server reachable, SSE connected, normal operation
|
||||
* 'reconnecting' — connection blip detected; 5-second grace before showing full modal
|
||||
* 'lost' — grace period expired, modal shown (Wait / Emergency)
|
||||
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
|
||||
*/
|
||||
|
||||
const GRACE_MS = 5_000
|
||||
|
||||
const useConnectionStore = create((set, get) => ({
|
||||
status: 'online', // 'online' | 'lost' | 'emergency'
|
||||
lostAt: null, // Date when connection was lost
|
||||
status: 'online', // 'online' | 'reconnecting' | 'lost' | 'emergency'
|
||||
lostAt: null,
|
||||
_graceTimer: null,
|
||||
|
||||
setLost: () => {
|
||||
if (get().status === 'online') {
|
||||
set({ status: 'lost', lostAt: new Date() })
|
||||
}
|
||||
const { status, _graceTimer } = get()
|
||||
// Already lost or in emergency — no-op
|
||||
if (status === 'lost' || status === 'emergency') return
|
||||
// Already in grace period — don't restart the timer
|
||||
if (status === 'reconnecting') return
|
||||
|
||||
// Start grace period
|
||||
const timer = setTimeout(() => {
|
||||
// Only escalate if we're still in reconnecting (not recovered in the meantime)
|
||||
if (get().status === 'reconnecting') {
|
||||
set({ status: 'lost', _graceTimer: null })
|
||||
}
|
||||
}, GRACE_MS)
|
||||
|
||||
set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: timer })
|
||||
},
|
||||
|
||||
setOnline: () => set({ status: 'online', lostAt: null }),
|
||||
setOnline: () => {
|
||||
const { _graceTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'online', lostAt: null, _graceTimer: null })
|
||||
},
|
||||
|
||||
enterEmergency: () => set({ status: 'emergency' }),
|
||||
enterEmergency: () => {
|
||||
const { _graceTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'emergency', _graceTimer: null })
|
||||
},
|
||||
|
||||
// Called when server comes back while in emergency mode — triggers sync then go online
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null }),
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null, _graceTimer: null }),
|
||||
|
||||
isOnline: () => get().status === 'online',
|
||||
isLost: () => get().status === 'lost',
|
||||
|
||||
@@ -13,8 +13,8 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'TableServe',
|
||||
short_name: 'TableServe',
|
||||
name: 'Xenia',
|
||||
short_name: 'Xenia',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0f172a',
|
||||
|
||||
Reference in New Issue
Block a user