feat: initial commit — local services (backend + manager dashboard + waiter PWA)

Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import useAuthStore from '../store/authStore'
import client from '../api/client'
const NotificationContext = createContext(null)
export function useNotifications() {
return useContext(NotificationContext)
}
// ─── Persistent banner (one message at a time, stacked) ───────────────────────
function NotificationBanner({ message, onAck }) {
const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
background: '#1e1b4b', border: '1px solid #6366f1',
borderRadius: 14, padding: '12px 14px',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'slideIn 0.25s ease',
}}>
<span style={{ fontSize: 22, flexShrink: 0 }}>📢</span>
<div style={{ flex: 1, minWidth: 0 }}>
{message.sender_name && (
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{message.sender_name}
</div>
)}
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
{message.body}
</div>
{tableIds.length > 0 && (
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
</div>
)}
</div>
<button
onClick={() => onAck(message.id)}
style={{
flexShrink: 0, height: 32, padding: '0 12px',
borderRadius: 8, border: 'none',
background: '#4f46e5', color: 'white',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}
>OK </button>
</div>
)
}
export function NotificationProvider({ children }) {
const { token, user } = useAuthStore()
const [pendingMessages, setPendingMessages] = useState([])
const [recentMessages, setRecentMessages] = useState([])
const fetchUnread = useCallback(async () => {
if (!token || !user) return
try {
const res = await client.get('/api/messages/unread')
setPendingMessages(res.data)
} catch { /* offline or unauthenticated — swallow */ }
}, [token, user?.id])
const fetchRecent = useCallback(async () => {
if (!token || !user) return
try {
const res = await client.get('/api/messages/recent?limit=10')
setRecentMessages(res.data)
} catch { }
}, [token, user?.id])
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
useEffect(() => {
if (!token || !user) return
fetchUnread()
fetchRecent()
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`)
setPendingMessages(prev => prev.filter(m => m.id !== messageId))
fetchRecent()
} catch { }
}
const unreadCount = pendingMessages.length
return (
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
{children}
{/* Floating banner stack (max 3 visible) */}
{pendingMessages.length > 0 && (
<div style={{
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
padding: '0 12px',
display: 'flex', flexDirection: 'column', gap: 8,
pointerEvents: 'none',
}}>
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
{pendingMessages.slice(0, 3).map(msg => (
<div key={msg.id} style={{ pointerEvents: 'all' }}>
<NotificationBanner message={msg} onAck={ackMessage} />
</div>
))}
{pendingMessages.length > 3 && (
<div style={{
textAlign: 'center', fontSize: 12, color: '#94a3b8',
pointerEvents: 'all',
}}>
+{pendingMessages.length - 3} ακόμα μηνύματα
</div>
)}
</div>
)}
</NotificationContext.Provider>
)
}

View File

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