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:
2026-05-21 15:24:54 +03:00
parent aa92623802
commit 5de89a722c
40 changed files with 1906 additions and 1171 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 }}>

View File

@@ -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(() => {

View File

@@ -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

View 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 })
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',