feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,15 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
BIN
waiter_pwa/public/fonts/GoogleSans-Variable-Italic.ttf
Normal file
BIN
waiter_pwa/public/fonts/GoogleSans-Variable-Italic.ttf
Normal file
Binary file not shown.
BIN
waiter_pwa/public/fonts/GoogleSans-Variable.ttf
Normal file
BIN
waiter_pwa/public/fonts/GoogleSans-Variable.ttf
Normal file
Binary file not shown.
@@ -6,6 +6,7 @@ import useShiftStore from './store/shiftStore'
|
||||
import useThemeStore from './store/themeStore'
|
||||
import useTableColourStore from './store/tableColourStore'
|
||||
import useConnectionStore from './store/connectionStore'
|
||||
import useWaiterFavoritesStore from './store/waiterFavoritesStore'
|
||||
import client from './api/client'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TableListPage from './pages/TableListPage'
|
||||
@@ -13,9 +14,16 @@ import TableDetailPage from './pages/TableDetailPage'
|
||||
import AddItemsPage from './pages/AddItemsPage'
|
||||
import OfflinePage from './pages/OfflinePage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import FavoritesSetupPage from './pages/FavoritesSetupPage'
|
||||
import ChatListPage from './pages/ChatListPage'
|
||||
import ChatThreadPage from './pages/ChatThreadPage'
|
||||
import ShiftOverviewPage from './pages/ShiftOverviewPage'
|
||||
import OrderLogPage from './pages/OrderLogPage'
|
||||
import { NotificationProvider } from './context/NotificationContext'
|
||||
import { SSEProvider } from './context/SSEContext'
|
||||
import ConnectionLostModal from './components/ConnectionLostModal'
|
||||
import ConnectivityBar from './components/ConnectivityBar'
|
||||
import AppShell from './components/AppShell'
|
||||
import UpdatePrompt from './components/UpdatePrompt'
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -278,7 +286,7 @@ function OfflineListener() {
|
||||
const { status } = useConnectionStore()
|
||||
useEffect(() => {
|
||||
function handler() {
|
||||
// If user is logged in, ConnectionLostModal handles it — don't redirect to /offline
|
||||
// If user is logged in, ConnectivityBar 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')
|
||||
@@ -297,6 +305,46 @@ function ThemeApplier() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prevents the Android back gesture/button from exiting the PWA.
|
||||
//
|
||||
// Strategy: whenever the browser lands on /tables (the root screen), immediately
|
||||
// push a sentinel entry so there is always one history entry behind it. If the
|
||||
// user backs into the sentinel we push it again — they can never back past /tables.
|
||||
//
|
||||
// Only active when running as an installed PWA (standalone display mode).
|
||||
function AndroidBackGuard() {
|
||||
useEffect(() => {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true
|
||||
if (!isStandalone) return
|
||||
|
||||
function pushSentinel() {
|
||||
history.pushState({ pwaGuard: true }, '')
|
||||
}
|
||||
|
||||
// If we're already on the root screen at mount time, push the sentinel now.
|
||||
if (window.location.pathname === '/tables' || window.location.pathname === '/') {
|
||||
pushSentinel()
|
||||
}
|
||||
|
||||
function onPopState(e) {
|
||||
if (e.state?.pwaGuard) {
|
||||
// User backed into the sentinel — push it again so there's always one more.
|
||||
pushSentinel()
|
||||
return
|
||||
}
|
||||
// User backed to /tables (the real root screen) — push the sentinel behind it.
|
||||
if (window.location.pathname === '/tables' || window.location.pathname === '/') {
|
||||
pushSentinel()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPopState)
|
||||
return () => window.removeEventListener('popstate', onPopState)
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function ColourLoader() {
|
||||
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
||||
useEffect(() => {
|
||||
@@ -310,6 +358,15 @@ function ColourLoader() {
|
||||
return null
|
||||
}
|
||||
|
||||
function FavoritesLoader() {
|
||||
const { token } = useAuthStore()
|
||||
const loadFromServer = useWaiterFavoritesStore(s => s.loadFromServer)
|
||||
useEffect(() => {
|
||||
if (token) loadFromServer()
|
||||
}, [token])
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Login guard — redirect to /tables if already authenticated ───────────────
|
||||
|
||||
function LoginGuard({ children }) {
|
||||
@@ -318,6 +375,26 @@ function LoginGuard({ children }) {
|
||||
return children
|
||||
}
|
||||
|
||||
// ─── ChatListPage wrapper — owns showNewChat state so pencil can live in AppShell ──
|
||||
|
||||
function ChatListRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ChatListPage />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── TableListPage wrapper ────────────────────────────────────────────────────
|
||||
|
||||
function TableListRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<TableListPage />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
@@ -327,22 +404,33 @@ export default function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeApplier />
|
||||
<AndroidBackGuard />
|
||||
<ColourLoader />
|
||||
<FavoritesLoader />
|
||||
<AuthRehydrator />
|
||||
<OfflineListener />
|
||||
<SSEProvider>
|
||||
<NotificationProvider>
|
||||
<ConnectionLostModal />
|
||||
<ConnectivityBar />
|
||||
<UpdatePrompt />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
<Route index element={<Navigate to="/tables?tab=tables" replace />} />
|
||||
<Route path="/tables" element={<TableListRoute />} />
|
||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||
<Route path="/standalone/:orderType/add" element={<AddItemsPage />} />
|
||||
<Route path="/orders/:orderId" element={<TableDetailPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/favorites" element={<FavoritesSetupPage />} />
|
||||
<Route path="/shift-overview" element={<ShiftOverviewPage />} />
|
||||
<Route path="/order-log" element={<OrderLogPage />} />
|
||||
<Route path="/messages" element={<ChatListRoute />} />
|
||||
<Route path="/messages/:conversationId" element={<ChatThreadPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||
<Route path="*" element={<Navigate to="/tables?tab=tables" replace />} />
|
||||
</Routes>
|
||||
</NotificationProvider>
|
||||
</SSEProvider>
|
||||
|
||||
663
waiter_pwa/src/components/AppShell.jsx
Normal file
663
waiter_pwa/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,663 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import UserMenu from './UserMenu'
|
||||
import { useNotifications } from '../context/NotificationContext'
|
||||
import useNotificationSettingsStore from '../store/notificationSettingsStore'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
import useChatStore from '../store/chatStore'
|
||||
import useKdsReadyStore from '../store/kdsReadyStore'
|
||||
import useOfflineQueueCount from '../hooks/useOfflineQueueCount'
|
||||
import useFailedPrintCount from '../hooks/useFailedPrintCount'
|
||||
|
||||
// ─── Nav item registry ────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_REGISTRY = {
|
||||
tables: {
|
||||
key: 'tables',
|
||||
label: 'Τραπέζια',
|
||||
shortLabel: 'Τραπέζια',
|
||||
href: '/tables?tab=tables',
|
||||
match: (loc) => loc.pathname === '/tables' && loc.search !== '?tab=orders',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" fill="currentColor"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" fill="currentColor"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" fill="currentColor"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
orders: {
|
||||
key: 'orders',
|
||||
label: 'Ενεργές Παραγγελίες',
|
||||
shortLabel: 'Παραγγελίες',
|
||||
href: '/tables?tab=orders',
|
||||
match: (loc) => loc.pathname === '/tables' && loc.search === '?tab=orders',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M9 12h6M9 16h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
messages: {
|
||||
key: 'messages',
|
||||
label: 'Μηνύματα',
|
||||
shortLabel: 'Μηνύματα',
|
||||
href: '/messages',
|
||||
match: (loc) => loc.pathname === '/messages',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
standalone: {
|
||||
key: 'standalone',
|
||||
label: 'Takeaway',
|
||||
shortLabel: 'Takeaway',
|
||||
href: '/tables',
|
||||
match: () => false,
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12H3l9-9 9 9h-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10 22v-6h4v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
'shift-overview': {
|
||||
key: 'shift-overview',
|
||||
label: 'Βάρδια',
|
||||
shortLabel: 'Βάρδια',
|
||||
href: '/shift-overview',
|
||||
match: (loc) => loc.pathname === '/shift-overview',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M12 7v5l3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
'order-log': {
|
||||
key: 'order-log',
|
||||
label: 'Αρχείο',
|
||||
shortLabel: 'Αρχείο',
|
||||
href: '/order-log',
|
||||
match: (loc) => loc.pathname === '/order-log',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 9V2h12v7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const DRAWER_ITEMS = [
|
||||
{ ...NAV_REGISTRY.tables },
|
||||
{ ...NAV_REGISTRY.orders },
|
||||
{ ...NAV_REGISTRY.messages },
|
||||
]
|
||||
|
||||
// ─── Cache badge (cloud upload) ───────────────────────────────────────────────
|
||||
|
||||
function CacheButton({ count }) {
|
||||
if (!count) return null
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 44, minHeight: 44, borderRadius: 8,
|
||||
color: '#f59e0b',
|
||||
}}>
|
||||
{/* Cloud upload icon */}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 16V10M12 10L9 13M12 10L15 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6.5 18a4.5 4.5 0 0 1-.5-8.965A6 6 0 0 1 17.5 11h.5a3.5 3.5 0 0 1 .5 6.965" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#f59e0b', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Failed print badge ───────────────────────────────────────────────────────
|
||||
|
||||
function FailedPrintButton({ count, onClick }) {
|
||||
if (!count) return null
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 44, minHeight: 44, borderRadius: 8,
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
{/* Printer icon */}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 9V2h12v7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Bell button ──────────────────────────────────────────────────────────────
|
||||
|
||||
function BellButton({ onClick, count }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'relative', background: 'none', border: 'none',
|
||||
color: 'var(--text)', cursor: 'pointer',
|
||||
minWidth: 44, minHeight: 44, borderRadius: 8,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{count > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notification drawer ──────────────────────────────────────────────────────
|
||||
|
||||
function NotificationDrawer({ messages, onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title" style={{ marginBottom: 8 }}>Ειδοποιήσεις</h2>
|
||||
{messages.length === 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '24px 0' }}>
|
||||
Δεν υπάρχουν ειδοποιήσεις
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', flex: 1 }}>
|
||||
{messages.map(msg => {
|
||||
const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })()
|
||||
return (
|
||||
<div key={msg.id} style={{
|
||||
padding: '12px 4px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||||
}}>
|
||||
<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: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>Τραπέζι: {tableIds.join(', ')}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 4 }} onClick={onClose}>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Side drawer ──────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAWER_NAV_ITEMS = [
|
||||
{ ...NAV_REGISTRY.tables },
|
||||
{
|
||||
key: 'standalone',
|
||||
label: 'Takeaway / Delivery',
|
||||
href: '/tables',
|
||||
match: () => false, // active state handled separately in SideDrawer
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12H3l9-9 9 9h-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10 22v-6h4v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ ...NAV_REGISTRY.orders },
|
||||
{ ...NAV_REGISTRY.messages },
|
||||
{
|
||||
key: 'shift-overview',
|
||||
label: 'Σύνοψη Βάρδιας',
|
||||
href: '/shift-overview',
|
||||
match: (loc) => loc.pathname === '/shift-overview',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M12 7v5l3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'order-log',
|
||||
label: 'Αρχείο Παραγγελιών',
|
||||
href: '/order-log',
|
||||
match: (loc) => loc.pathname === '/order-log',
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 9V2h12v7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const DRAWER_SETTINGS_ITEM = {
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
match: (loc) => loc.pathname.startsWith('/settings'),
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" stroke="currentColor" strokeWidth="2"/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
function DrawerButton({ item, location, onNavigate, active: activeProp, onClick }) {
|
||||
const active = activeProp ?? item.match(location)
|
||||
return (
|
||||
<button
|
||||
onClick={onClick ?? (() => onNavigate(item.href))}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none',
|
||||
background: active ? 'var(--accent)18' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--text)',
|
||||
fontSize: 15, fontWeight: active ? 700 : 500,
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: active ? 1 : 0.6, flexShrink: 0 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
{active && (
|
||||
<div style={{ marginLeft: 'auto', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SideDrawer({ open, onClose }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { activeZoneTab, setActiveZoneTab } = useTableViewStore()
|
||||
|
||||
function go(href) {
|
||||
if (href.startsWith('/tables')) setActiveZoneTab('all')
|
||||
onClose()
|
||||
navigate(href, { replace: true })
|
||||
}
|
||||
|
||||
function goStandalone() {
|
||||
setActiveZoneTab('standalone')
|
||||
onClose()
|
||||
navigate('/tables', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 300,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
opacity: open ? 1 : 0,
|
||||
pointerEvents: open ? 'all' : 'none',
|
||||
transition: 'opacity 0.22s ease',
|
||||
}}
|
||||
/>
|
||||
{/* Drawer panel */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, bottom: 0,
|
||||
width: 260, zIndex: 301,
|
||||
background: 'var(--bg2)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transform: open ? 'translateX(0)' : 'translateX(-100%)',
|
||||
transition: 'transform 0.22s ease',
|
||||
boxShadow: open ? '4px 0 24px rgba(0,0,0,0.4)' : 'none',
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ padding: '20px 20px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--accent)', letterSpacing: -0.5 }}>Xenia</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>POS System</div>
|
||||
</div>
|
||||
|
||||
{/* Main nav items */}
|
||||
<div style={{ flex: 1, padding: '12px 10px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1, padding: '4px 10px 8px' }}>
|
||||
Πλοήγηση
|
||||
</div>
|
||||
{DRAWER_NAV_ITEMS.map(item => {
|
||||
const isStandaloneActive = location.pathname === '/tables' && activeZoneTab === 'standalone'
|
||||
const active = item.key === 'standalone'
|
||||
? isStandaloneActive
|
||||
: item.key === 'tables'
|
||||
? item.match(location) && !isStandaloneActive
|
||||
: undefined
|
||||
return (
|
||||
<DrawerButton
|
||||
key={item.key}
|
||||
item={item}
|
||||
location={location}
|
||||
onNavigate={go}
|
||||
active={active}
|
||||
onClick={item.key === 'standalone' ? goStandalone : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Settings at bottom */}
|
||||
<div style={{ padding: '8px 10px 16px', borderTop: '1px solid var(--border)' }}>
|
||||
<DrawerButton item={DRAWER_SETTINGS_ITEM} location={location} onNavigate={go} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Triple pill footer ───────────────────────────────────────────────────────
|
||||
|
||||
function PillBadge({ count, color = '#ef4444' }) {
|
||||
if (!count) return null
|
||||
return (
|
||||
<span style={{
|
||||
background: color, color: 'white', fontSize: 12, fontWeight: 800,
|
||||
borderRadius: 11, minWidth: 22, height: 22,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 6px', lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TripleFooter({ location, navigate, chatUnread, kdsReady, tripleItems }) {
|
||||
const { activeZoneTab, setActiveZoneTab } = useTableViewStore()
|
||||
const items = (tripleItems || ['tables', 'orders', 'messages']).map(k => NAV_REGISTRY[k]).filter(Boolean)
|
||||
|
||||
function handleNavClick(item) {
|
||||
if (item.key === 'standalone') { setActiveZoneTab('standalone'); navigate('/tables', { replace: true }); return }
|
||||
if (item.key === 'tables' || item.key === 'orders') setActiveZoneTab('all')
|
||||
navigate(item.href, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '10px 10px',
|
||||
background: 'var(--bg2)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg3)', borderRadius: 28, padding: 4, width: '100%' }}>
|
||||
{items.map(item => {
|
||||
const isStandaloneActive = location.pathname === '/tables' && activeZoneTab === 'standalone'
|
||||
const active = item.key === 'standalone'
|
||||
? isStandaloneActive
|
||||
: item.key === 'tables'
|
||||
? item.match(location) && !isStandaloneActive
|
||||
: item.match(location)
|
||||
const badge = item.key === 'messages' && chatUnread > 0
|
||||
? { count: chatUnread, color: '#ef4444' }
|
||||
: item.key === 'orders' && kdsReady > 0
|
||||
? { count: kdsReady, color: '#22c55e' }
|
||||
: null
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => handleNavClick(item)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
flex: 1,
|
||||
padding: '9px 8px', borderRadius: 22,
|
||||
border: '2px solid transparent',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
fontSize: 14, fontWeight: 700, cursor: 'pointer', lineHeight: 1,
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
{item.key === 'tables' && <svg width="15" height="15" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="1.5" fill="currentColor"/><rect x="14" y="3" width="7" height="7" rx="1.5" fill="currentColor"/><rect x="3" y="14" width="7" height="7" rx="1.5" fill="currentColor"/><rect x="14" y="14" width="7" height="7" rx="1.5" fill="currentColor"/></svg>}
|
||||
{item.key === 'orders' && <svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/><path d="M9 12h6M9 16h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/></svg>}
|
||||
{item.key === 'messages' && <svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
{item.shortLabel ?? item.label}
|
||||
{badge && <PillBadge count={badge.count} color={badge.color} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Icon navbar footer ───────────────────────────────────────────────────────
|
||||
|
||||
function IconNavbar({ navItems, location, navigate, chatUnread, kdsReady }) {
|
||||
const { activeZoneTab, setActiveZoneTab } = useTableViewStore()
|
||||
const items = navItems
|
||||
.map(key => NAV_REGISTRY[key])
|
||||
.filter(Boolean)
|
||||
|
||||
function handleNavClick(item) {
|
||||
if (item.key === 'standalone') { setActiveZoneTab('standalone'); navigate('/tables', { replace: true }); return }
|
||||
if (item.key === 'tables' || item.key === 'orders') setActiveZoneTab('all')
|
||||
navigate(item.href, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
padding: '0 8px',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
background: 'var(--bg2)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
minHeight: 60,
|
||||
}}>
|
||||
{items.map(item => {
|
||||
const isStandaloneActive = location.pathname === '/tables' && activeZoneTab === 'standalone'
|
||||
const active = item.key === 'standalone'
|
||||
? isStandaloneActive
|
||||
: item.key === 'tables'
|
||||
? item.match(location) && !isStandaloneActive
|
||||
: item.match(location)
|
||||
const badge = item.key === 'messages' && chatUnread > 0
|
||||
? { count: chatUnread, color: '#ef4444' }
|
||||
: item.key === 'orders' && kdsReady > 0
|
||||
? { count: kdsReady, color: '#22c55e' }
|
||||
: null
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => handleNavClick(item)}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: 'none', background: 'none', cursor: 'pointer',
|
||||
color: active ? 'var(--accent)' : 'var(--muted)',
|
||||
padding: '10px 4px',
|
||||
position: 'relative',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: '25%', right: '25%',
|
||||
height: 2, borderRadius: '0 0 2px 2px',
|
||||
background: 'var(--accent)',
|
||||
}} />
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{item.icon}
|
||||
{badge && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -4, right: -6,
|
||||
background: badge.color, color: 'white', fontSize: 9, fontWeight: 800,
|
||||
borderRadius: '50%', minWidth: 14, height: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 2px',
|
||||
}}>
|
||||
{badge.count > 9 ? '9+' : badge.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, fontWeight: active ? 700 : 500, letterSpacing: 0.2 }}>
|
||||
{item.shortLabel ?? item.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AppShell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppShell({ children, title, rightActions }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const { unreadCount, recentMessages, fetchRecent, ackAll } = useNotifications() || {}
|
||||
const { popupMode } = useNotificationSettingsStore()
|
||||
const { navStyle, navItems, tripleItems } = useTableViewStore()
|
||||
const { totalUnread: chatUnread } = useChatStore()
|
||||
const { readyCount } = useKdsReadyStore()
|
||||
const offlineCacheCount = useOfflineQueueCount()
|
||||
const failedPrintCount = useFailedPrintCount()
|
||||
|
||||
function openNotifs() {
|
||||
setShowNotifs(true)
|
||||
fetchRecent?.()
|
||||
if (popupMode === 'never') ackAll?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* ── Header ────────────────────────────────────────────── */}
|
||||
<header className="top-bar">
|
||||
{/* Hamburger menu */}
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text)', minWidth: 44, minHeight: 44, borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Title / right-actions area */}
|
||||
{title && (
|
||||
<span className="top-bar__title">{title}</span>
|
||||
)}
|
||||
{!title && <div style={{ flex: 1 }} />}
|
||||
|
||||
{/* Custom right actions (per-page) */}
|
||||
{rightActions}
|
||||
|
||||
{/* Offline cache badge — visible only when ops are pending */}
|
||||
<CacheButton count={offlineCacheCount} />
|
||||
|
||||
{/* Failed print badge — navigates to order log */}
|
||||
<FailedPrintButton count={failedPrintCount} onClick={() => navigate('/order-log?filter=failed')} />
|
||||
|
||||
{/* Bell */}
|
||||
<BellButton onClick={openNotifs} count={unreadCount || 0} />
|
||||
|
||||
{/* Username chip */}
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{/* ── Side drawer ───────────────────────────────────────── */}
|
||||
<SideDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
{/* ── Page content ──────────────────────────────────────── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* ── Footer nav ────────────────────────────────────────── */}
|
||||
{navStyle === 'icon_navbar' ? (
|
||||
<IconNavbar
|
||||
navItems={navItems}
|
||||
location={location}
|
||||
navigate={navigate}
|
||||
chatUnread={chatUnread}
|
||||
kdsReady={readyCount}
|
||||
/>
|
||||
) : (
|
||||
<TripleFooter
|
||||
location={location}
|
||||
navigate={navigate}
|
||||
chatUnread={chatUnread}
|
||||
kdsReady={readyCount}
|
||||
tripleItems={tripleItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Notification drawer ───────────────────────────────── */}
|
||||
{showNotifs && (
|
||||
<NotificationDrawer
|
||||
messages={recentMessages || []}
|
||||
onClose={() => setShowNotifs(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export default function ConnectionBanner() {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textAlign: 'center',
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠ Cannot reach the system — check your WiFi
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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
|
||||
|
||||
export default function ConnectionLostModal() {
|
||||
const { status, setOnline, enterEmergency } = useConnectionStore()
|
||||
const { reconnect, fullRefresh } = useSSEContext()
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const retryRef = useRef(null)
|
||||
|
||||
const isReconnecting = status === 'reconnecting'
|
||||
const isLost = status === 'lost'
|
||||
|
||||
async function tryReconnect() {
|
||||
setRetrying(true)
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
setOnline()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
} catch {
|
||||
// Still down — stay in modal
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry every 10s while the full "lost" modal is open
|
||||
useEffect(() => {
|
||||
if (!isLost) { clearInterval(retryRef.current); return }
|
||||
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
|
||||
return () => clearInterval(retryRef.current)
|
||||
}, [isLost])
|
||||
|
||||
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,
|
||||
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>
|
||||
)
|
||||
}
|
||||
76
waiter_pwa/src/components/ConnectivityBar.jsx
Normal file
76
waiter_pwa/src/components/ConnectivityBar.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
|
||||
/**
|
||||
* A 3px bar pinned to the very top of the screen (above the header).
|
||||
* Always visible — color signals connection state at a glance:
|
||||
*
|
||||
* sseAlive=true → solid green (SSE stream live)
|
||||
* online only → solid blue (HTTP reachable, SSE not yet connected)
|
||||
* reconnecting → amber, animated sweep
|
||||
* offline → red, animated sweep
|
||||
* offline >5min → flashing red
|
||||
*/
|
||||
export default function ConnectivityBar() {
|
||||
const { status, sseAlive, flashing } = useConnectionStore()
|
||||
|
||||
const isOnline = status === 'online'
|
||||
const isReconnecting = status === 'reconnecting'
|
||||
const isOffline = status === 'offline'
|
||||
|
||||
const color = isOnline
|
||||
? (sseAlive ? '#22c55e' : '#3b82f6')
|
||||
: isReconnecting
|
||||
? '#f59e0b'
|
||||
: '#ef4444'
|
||||
|
||||
const animated = !isOnline
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes connectivity-sweep {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 300% center; }
|
||||
}
|
||||
@keyframes connectivity-flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.15; }
|
||||
}
|
||||
.connectivity-bar {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.connectivity-bar--sweep {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cb-color) 0%,
|
||||
var(--cb-color) 30%,
|
||||
color-mix(in srgb, var(--cb-color) 20%, transparent) 50%,
|
||||
var(--cb-color) 70%,
|
||||
var(--cb-color) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: connectivity-sweep 1.8s ease-in-out infinite;
|
||||
}
|
||||
.connectivity-bar--flash {
|
||||
animation: connectivity-flash 0.7s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'connectivity-bar',
|
||||
animated && !flashing ? 'connectivity-bar--sweep' : '',
|
||||
flashing ? 'connectivity-bar--flash' : '',
|
||||
].join(' ')}
|
||||
style={{
|
||||
'--cb-color': color,
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 6px 1px ${color}99`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
// Unit metadata: step, min, quick-preset values, quick ± offset buttons
|
||||
const UNIT_CONFIG = {
|
||||
kg: { step: 0.1, min: 0.1, presets: [0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], offsets: [0.2, 0.5, 1.0], label: 'kg' },
|
||||
liter: { step: 0.1, min: 0.1, presets: [0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], offsets: [0.2, 0.5, 1.0], label: 'L' },
|
||||
gram: { step: 10, min: 10, presets: [50, 100, 150, 200, 250, 300, 400, 500], offsets: [50, 100, 200], label: 'g' },
|
||||
ml: { step: 5, min: 5, presets: [25, 50, 100, 150, 200, 250, 300, 500], offsets: [25, 50, 100], label: 'mL' },
|
||||
}
|
||||
const DECIMAL_UNITS = new Set(Object.keys(UNIT_CONFIG))
|
||||
|
||||
function fmtQty(qty, unitCfg) {
|
||||
if (!unitCfg) return String(qty)
|
||||
if (unitCfg.label === 'kg' || unitCfg.label === 'L') return Number(qty).toFixed(1)
|
||||
return String(qty)
|
||||
}
|
||||
|
||||
export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const [selectedOptions, setSelectedOptions] = useState([])
|
||||
const [removedIngredients, setRemovedIngredients] = useState([])
|
||||
const [notes, setNotes] = useState('')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const unitCfg = UNIT_CONFIG[product.unit_type] ?? null
|
||||
const isDecimal = DECIMAL_UNITS.has(product.unit_type)
|
||||
const [quantity, setQuantity] = useState(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
|
||||
const [qtyInput, setQtyInput] = useState('')
|
||||
const [editingQty, setEditingQty] = useState(false)
|
||||
const qtyRef = useRef(null)
|
||||
|
||||
const options = product.options || []
|
||||
const ingredients = product.ingredients || []
|
||||
const preferenceSets = product.preference_sets || []
|
||||
|
||||
// selectedPreferences: { [setId]: choice | null }
|
||||
// selectedPreferences: { [setId]: choice | null } (single-select sets)
|
||||
const [selectedPreferences, setSelectedPreferences] = useState(() =>
|
||||
Object.fromEntries(
|
||||
preferenceSets.map(ps => {
|
||||
preferenceSets.filter(ps => !ps.allow_multi_select).map(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
@@ -22,10 +43,22 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
)
|
||||
)
|
||||
|
||||
// multiPreferences: { [setId]: Array<{choice, qty}> } (multi-select sets)
|
||||
const [multiPreferences, setMultiPreferences] = useState(() =>
|
||||
Object.fromEntries(
|
||||
preferenceSets.filter(ps => ps.allow_multi_select).map(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
return [ps.id, def ? [{ choice: def, qty: 1 }] : []]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Per-preference-choice inline sub-choices: { [choiceId]: subChoice | null }
|
||||
const [selectedSubChoices, setSelectedSubChoices] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
preferenceSets.filter(ps => !ps.allow_multi_select).forEach(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
@@ -40,7 +73,7 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
// Shared-subset selections: { [setId]: subChoice | null }
|
||||
const [selectedSharedSubs, setSelectedSharedSubs] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
preferenceSets.filter(ps => !ps.allow_multi_select).forEach(ps => {
|
||||
if (ps.shared_subset?.choices?.length > 0) {
|
||||
const selectedChoice = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
@@ -75,6 +108,27 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMultiChoice(setId, choice) {
|
||||
setMultiPreferences(prev => {
|
||||
const current = prev[setId] ?? []
|
||||
const idx = current.findIndex(e => e.choice.id === choice.id)
|
||||
if (idx >= 0) return { ...prev, [setId]: current.filter((_, i) => i !== idx) }
|
||||
return { ...prev, [setId]: [...current, { choice, qty: 1 }] }
|
||||
})
|
||||
}
|
||||
|
||||
function setMultiChoiceQty(setId, choiceId, delta) {
|
||||
setMultiPreferences(prev => {
|
||||
const current = prev[setId] ?? []
|
||||
return {
|
||||
...prev,
|
||||
[setId]: current.map(e =>
|
||||
e.choice.id === choiceId ? { ...e, qty: Math.max(1, e.qty + delta) } : e
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function selectSubChoice(parentChoiceId, sub) {
|
||||
setSelectedSubChoices(prev => ({ ...prev, [parentChoiceId]: sub }))
|
||||
}
|
||||
@@ -117,6 +171,9 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
})
|
||||
|
||||
function isPrefSetComplete(ps) {
|
||||
if (ps.allow_multi_select) {
|
||||
return (multiPreferences[ps.id] ?? []).length > 0
|
||||
}
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (choice == null) return false
|
||||
if (choice.sub_choices?.length > 0 && selectedSubChoices[choice.id] == null) return false
|
||||
@@ -129,6 +186,9 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const canAdd = allPrefsSelected && !optionSubsMissing
|
||||
|
||||
const prefExtra = preferenceSets.reduce((s, ps) => {
|
||||
if (ps.allow_multi_select) {
|
||||
return s + (multiPreferences[ps.id] ?? []).reduce((acc, { choice, qty }) => acc + (choice.extra_cost ?? 0) * qty, 0)
|
||||
}
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return s
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||
@@ -145,6 +205,14 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
function handleAdd() {
|
||||
if (!canAdd) return
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
if (ps.allow_multi_select) {
|
||||
return (multiPreferences[ps.id] ?? []).map(({ choice, qty }) => ({
|
||||
id: choice.id,
|
||||
name: qty > 1 ? `${choice.name} x${qty}` : choice.name,
|
||||
price_delta: (choice.extra_cost ?? 0) * qty,
|
||||
type: 'pref',
|
||||
}))
|
||||
}
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return []
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
|
||||
@@ -227,6 +295,47 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
{/* ── Preference sets ── */}
|
||||
{preferenceSets.map(ps => {
|
||||
const missing = !isPrefSetComplete(ps)
|
||||
|
||||
if (ps.allow_multi_select) {
|
||||
const selected = multiPreferences[ps.id] ?? []
|
||||
return (
|
||||
<section key={ps.id} className="modal-section"
|
||||
style={missing ? { border: '1.5px solid #ef4444', borderRadius: 10, padding: '10px 12px' } : {}}>
|
||||
<h3 style={{ color: missing ? '#ef4444' : undefined }}>
|
||||
{ps.name}
|
||||
{missing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— επιλέξτε τουλάχιστον μία</span>}
|
||||
</h3>
|
||||
{ps.choices.map(ch => {
|
||||
const entry = selected.find(e => e.choice.id === ch.id)
|
||||
const isChecked = !!entry
|
||||
return (
|
||||
<div key={ch.id} style={{ marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label className="modal-option" style={{ flex: 1 }}>
|
||||
<input type="checkbox" checked={isChecked}
|
||||
onChange={() => toggleMultiChoice(ps.id, ch)} />
|
||||
<span>{ch.name}</span>
|
||||
{(ch.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{ch.extra_cost > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
{isChecked && ps.allow_choice_quantity && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<button className="qty-btn" style={{ width: 28, height: 28, fontSize: 16 }}
|
||||
onClick={() => setMultiChoiceQty(ps.id, ch.id, -1)}>−</button>
|
||||
<span style={{ minWidth: 20, textAlign: 'center', fontWeight: 600, fontSize: 15 }}>{entry.qty}</span>
|
||||
<button className="qty-btn" style={{ width: 28, height: 28, fontSize: 16 }}
|
||||
onClick={() => setMultiChoiceQty(ps.id, ch.id, 1)}>+</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedChoice = selectedPreferences[ps.id] ?? null
|
||||
const showSharedSubset = ps.shared_subset?.choices?.length > 0
|
||||
&& selectedChoice != null
|
||||
@@ -325,11 +434,71 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
|
||||
</section>
|
||||
|
||||
<div className="modal-qty">
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.max(1, q - 1))}>−</button>
|
||||
<span className="qty-value">{quantity}</span>
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||
</div>
|
||||
{isDecimal ? (
|
||||
<div className="qty-decimal-panel">
|
||||
{/* Main ‒ / value / + row */}
|
||||
<div className="modal-qty">
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.max(unitCfg.min, Math.round((q - unitCfg.step) * 1000) / 1000))}>−</button>
|
||||
{editingQty ? (
|
||||
<input
|
||||
ref={qtyRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="qty-decimal-input"
|
||||
value={qtyInput}
|
||||
onChange={e => setQtyInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(qtyInput)
|
||||
if (!isNaN(v) && v >= unitCfg.min) setQuantity(Math.round(v * 1000) / 1000)
|
||||
setEditingQty(false)
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="qty-value" style={{ cursor: 'text', minWidth: 64 }}
|
||||
onClick={() => { setQtyInput(String(quantity)); setEditingQty(true) }}>
|
||||
{fmtQty(quantity, unitCfg)} <span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7 }}>{unitCfg.label}</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.round((q + unitCfg.step) * 1000) / 1000)}>+</button>
|
||||
</div>
|
||||
|
||||
{/* Quick preset values */}
|
||||
<div className="qty-presets">
|
||||
{unitCfg.presets.map(v => (
|
||||
<button key={v} className={`qty-preset-btn${quantity === v ? ' qty-preset-btn--active' : ''}`}
|
||||
onClick={() => setQuantity(v)}>
|
||||
{fmtQty(v, unitCfg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick ± offset buttons */}
|
||||
<div className="qty-offsets">
|
||||
{unitCfg.offsets.map(off => (
|
||||
<button key={`+${off}`} className="qty-offset-btn qty-offset-btn--add"
|
||||
onClick={() => setQuantity(q => Math.round((q + off) * 1000) / 1000)}>
|
||||
+{fmtQty(off, unitCfg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qty-offsets">
|
||||
{unitCfg.offsets.map(off => (
|
||||
<button key={`-${off}`} className="qty-offset-btn qty-offset-btn--sub"
|
||||
onClick={() => setQuantity(q => Math.max(unitCfg.min, Math.round((q - off) * 1000) / 1000))}>
|
||||
−{fmtQty(off, unitCfg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal-qty">
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.max(1, q - 1))}>−</button>
|
||||
<span className="qty-value">{quantity}</span>
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allPrefsSelected && (
|
||||
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 8 }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,26 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Number(v).toFixed(2) + ' €'
|
||||
}
|
||||
|
||||
const UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
|
||||
function fmtQty(qty, unitType) {
|
||||
const label = UNIT_LABELS[unitType]
|
||||
if (!label) return `×${qty}`
|
||||
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
|
||||
return `${qty}${label}`
|
||||
}
|
||||
|
||||
function fmtDateTime(iso) {
|
||||
if (!iso) return null
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionIcon({ type }) {
|
||||
@@ -17,7 +34,24 @@ function SectionIcon({ type }) {
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||||
}
|
||||
|
||||
// ── Parse selected_options into grouped sections (same logic as cart) ────────
|
||||
function ExpandArrow({ expanded, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onClick() }}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
|
||||
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Parse selected_options into grouped sections ──────────────────────────────
|
||||
|
||||
function buildSections(item) {
|
||||
const sections = []
|
||||
@@ -28,24 +62,13 @@ function buildSections(item) {
|
||||
try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] }
|
||||
})()
|
||||
|
||||
// We don't have product metadata here, so we classify by heuristics:
|
||||
// - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them
|
||||
// - id == null → sub-choice (follows its parent)
|
||||
// Strategy: walk through opts in order, attaching sub-choices to their parent,
|
||||
// then classify parent items: items with a real id that appear multiple times → extra (stacked),
|
||||
// but without product metadata we can't fully distinguish prefs from extras.
|
||||
// We use a simple rule: if an option with id appears only once in the stream → treat as pref
|
||||
// (since extras can be added multiple times). This matches how handleAdd() emits them.
|
||||
const prefGroups = []
|
||||
const extraGroups = []
|
||||
const quickLines = []
|
||||
|
||||
const prefGroups = [] // { setName: null (unknown), values: [...] }
|
||||
const extraGroups = [] // { id, name, subName, qty }
|
||||
const quickLines = [] // { name, _qty }
|
||||
|
||||
// Count how many times each id appears (extras can be stacked → appear multiple times)
|
||||
const idCount = {}
|
||||
opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 })
|
||||
|
||||
// Single pass: consume each item and its optional following sub (id=null)
|
||||
const consumedAsSubAtIndex = new Set()
|
||||
let i = 0
|
||||
while (i < opts.length) {
|
||||
@@ -53,7 +76,6 @@ function buildSections(item) {
|
||||
if (consumedAsSubAtIndex.has(i)) { i++; continue }
|
||||
|
||||
if (o.id == null) {
|
||||
// Standalone id=null → quick option
|
||||
const existing = quickLines.find(x => x.name === o.name)
|
||||
if (existing) existing._qty = (existing._qty || 1) + 1
|
||||
else quickLines.push({ name: o.name, _qty: 1 })
|
||||
@@ -61,7 +83,6 @@ function buildSections(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
// id != null — look ahead for immediate sub
|
||||
let subName = null
|
||||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||
subName = opts[i + 1].name
|
||||
@@ -69,12 +90,10 @@ function buildSections(item) {
|
||||
}
|
||||
|
||||
if (idCount[o.id] > 1) {
|
||||
// Extra — appears multiple times in the list
|
||||
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||||
if (existing) existing.qty++
|
||||
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||||
} else {
|
||||
// Single occurrence → preference choice
|
||||
const value = subName ? `${o.name} · ${subName}` : o.name
|
||||
prefGroups.push({ setName: null, values: [value] })
|
||||
}
|
||||
@@ -90,23 +109,231 @@ function buildSections(item) {
|
||||
return sections
|
||||
}
|
||||
|
||||
// ── KDS colour strip ──────────────────────────────────────────────────────────
|
||||
|
||||
const KDS_COLORS = {
|
||||
pending: { textColor: '#f59e0b', label: 'ΕΚΚΡΕΜΕΙ', icon: '⏳', dot: '#f59e0b' },
|
||||
preparing: { textColor: '#60a5fa', label: 'ΕΤΟΙΜΑΖΕΤΑΙ', icon: '🔥', dot: '#60a5fa' },
|
||||
done: { textColor: '#4ade80', label: 'READY', icon: '✓', dot: '#4ade80' },
|
||||
served: { textColor: null, label: null, icon: null, dot: null },
|
||||
}
|
||||
|
||||
// ── Shared expanded details panel ─────────────────────────────────────────────
|
||||
|
||||
function OptionsPanel({ sections }) {
|
||||
return (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="prefs" />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||
{line.setName && (
|
||||
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="quick" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="extras" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line.subName && <span> · {line.subName}</span>}
|
||||
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="removed" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="note" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── KDS badge/icon renderer ───────────────────────────────────────────────────
|
||||
|
||||
function KdsBadge({ kdsColor, kdsDisplayStyle }) {
|
||||
if (!kdsColor?.label) return null
|
||||
if (kdsDisplayStyle === 'icon' && kdsColor.dot) {
|
||||
return (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: kdsColor.dot, flexShrink: 0, display: 'inline-block',
|
||||
}} title={kdsColor.label} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 800, letterSpacing: 0.4,
|
||||
color: kdsColor.textColor,
|
||||
borderRadius: 3, padding: '1px 5px',
|
||||
border: `1px solid ${kdsColor.textColor}60`,
|
||||
flexShrink: 0,
|
||||
}}>{kdsColor.label}</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Price event label helpers ─────────────────────────────────────────────────
|
||||
|
||||
const PRICING_EVENT_STYLE = {
|
||||
modifier_applied: { color: '#60a5fa', label: null }, // label comes from modifier_name
|
||||
waiter_discount: { color: '#c084fc', label: 'Έκπτωση' },
|
||||
free_item_added: { color: '#4ade80', label: 'Δωρεάν' },
|
||||
deal_offer_accepted: { color: '#4ade80', label: null }, // label from deal_name
|
||||
}
|
||||
|
||||
function PricingRows({ events, layout, nameIndent = 12 }) {
|
||||
const rows = events.filter(ev =>
|
||||
ev.event_type === 'modifier_applied' ||
|
||||
ev.event_type === 'waiter_discount' ||
|
||||
ev.event_type === 'free_item_added'
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
// Match OptionsPanel container padding: section divider + inner padding
|
||||
return (
|
||||
<div>
|
||||
<div style={{ margin: `0 12px`, height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: `4px 12px 4px ${nameIndent}px`, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{rows.map((ev, i) => {
|
||||
const evStyle = PRICING_EVENT_STYLE[ev.event_type] || { color: '#94a3b8', label: ev.event_type }
|
||||
const label = ev.modifier_name || ev.deal_name || evStyle.label || ev.event_type
|
||||
const delta = ev.delta_amount
|
||||
const afterPrice = ev.price_after
|
||||
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
|
||||
<circle cx="9" cy="9" r="2" stroke={evStyle.color} strokeWidth="2"/>
|
||||
<circle cx="15" cy="15" r="2" stroke={evStyle.color} strokeWidth="2"/>
|
||||
<path d="M5 19L19 5" stroke={evStyle.color} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 12, color: evStyle.color, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
|
||||
{label}
|
||||
</span>
|
||||
{delta != null && (
|
||||
<span style={{ fontSize: 12, color: evStyle.color, fontWeight: 700, flexShrink: 0, marginLeft: 8 }}>
|
||||
{delta > 0 ? '+' : ''}{delta.toFixed(2)} €
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Small badge shown on the main item row to hint that pricing changes exist
|
||||
function PricingBadge({ events }) {
|
||||
if (!events || events.length === 0) return null
|
||||
const hasDiscount = events.some(ev => ev.event_type === 'waiter_discount')
|
||||
const hasModifier = events.some(ev => ev.event_type === 'modifier_applied')
|
||||
const hasFree = events.some(ev => ev.event_type === 'free_item_added')
|
||||
|
||||
const icons = []
|
||||
if (hasModifier) icons.push(
|
||||
<svg key="mod" width="10" height="10" viewBox="0 0 24 24" fill="none" title="Τροποποιητής">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
if (hasDiscount) icons.push(
|
||||
<svg key="disc" width="10" height="10" viewBox="0 0 24 24" fill="none" title="Έκπτωση">
|
||||
<circle cx="9" cy="9" r="2" stroke="#c084fc" strokeWidth="2"/>
|
||||
<circle cx="15" cy="15" r="2" stroke="#c084fc" strokeWidth="2"/>
|
||||
<path d="M5 19L19 5" stroke="#c084fc" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
if (hasFree) icons.push(
|
||||
<svg key="free" width="10" height="10" viewBox="0 0 24 24" fill="none" title="Δωρεάν">
|
||||
<path d="M20 12v10H4V12M22 7H2v5h20V7z" stroke="#4ade80" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
if (icons.length === 0) return null
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, marginLeft: 3 }}>
|
||||
{icons}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Deal badge on items that triggered or were added by a deal
|
||||
function DealBadge({ item }) {
|
||||
if (item.deal_id) {
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(167,139,250,0.15)', color: '#a78bfa',
|
||||
border: '1px solid rgba(167,139,250,0.4)', flexShrink: 0, letterSpacing: 0.3,
|
||||
}}>
|
||||
ΔΩΡΕΑΝ
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function MergedBadge({ item }) {
|
||||
if (!item._isMerged) return null
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(99,102,241,0.15)', color: '#a5b4fc',
|
||||
border: '1px solid rgba(99,102,241,0.35)', flexShrink: 0, letterSpacing: 0.3,
|
||||
}}>
|
||||
∑{item._sourceIds?.length}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── ItemRow ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
|
||||
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast, kdsDisplayStyle, orderItemLayout, waiterMap, courses, priceEvents }) {
|
||||
const isPaid = item.status === 'paid'
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
const effectiveKds = item.kds_status || 'pending'
|
||||
const isServed = effectiveKds === 'served'
|
||||
const kdsColor = !isCancelled && !isServed ? KDS_COLORS[effectiveKds] : null
|
||||
|
||||
const sections = buildSections(item)
|
||||
const hasDetails = sections.length > 0
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Long-press detection
|
||||
const pressTimer = useRef(null)
|
||||
const didLongPress = useRef(false)
|
||||
const touchStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
const canSelect = selectable && !isCancelled
|
||||
|
||||
function handleTouchStart(e) {
|
||||
if (!selectable || isPaid || isCancelled || !onLongPress) return
|
||||
if (!canSelect || !onLongPress) return
|
||||
didLongPress.current = false
|
||||
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
pressTimer.current = setTimeout(() => {
|
||||
@@ -125,98 +352,292 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
||||
|
||||
function handleBodyClick() {
|
||||
if (didLongPress.current) { didLongPress.current = false; return }
|
||||
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
||||
if (canSelect) onToggle(item.id)
|
||||
}
|
||||
|
||||
const nameColor = !isCancelled && !isServed ? kdsColor?.textColor : null
|
||||
const totalPrice = fmtPrice(((item.unit_price ?? 0) + (item.price_adjustment ?? 0)) * item.quantity)
|
||||
const unitPriceAdjusted = (item.unit_price ?? 0) + (item.price_adjustment ?? 0)
|
||||
// Cast to boolean so JSX never renders the raw number 0
|
||||
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
|
||||
|
||||
const courseBadge = item.course_id != null && courses?.length > 0 ? (() => {
|
||||
const course = courses.find(c => c.id === item.course_id)
|
||||
if (!course) return null
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
background: course.color, color: '#fff',
|
||||
fontSize: 9, fontWeight: 700, flexShrink: 0, marginLeft: 4,
|
||||
}}>
|
||||
{courses.indexOf(course) + 1}
|
||||
</span>
|
||||
)
|
||||
})() : null
|
||||
|
||||
const nameEl = (
|
||||
<span
|
||||
className={[
|
||||
'order-item__name',
|
||||
isCancelled ? 'order-item__name--cancelled' : '',
|
||||
isServed ? 'order-item__name--served' : '',
|
||||
effectiveKds === 'done' && !isCancelled && !isServed ? 'kds-ready-flash-text' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={nameColor ? { color: nameColor } : undefined}
|
||||
>
|
||||
{item.product?.name || `#${item.product_id}`}
|
||||
{courseBadge}
|
||||
</span>
|
||||
)
|
||||
|
||||
const paidBadge = isPaid && !isServed ? <span className="badge badge--paid">Paid</span> : null
|
||||
const cancelledBadge = isCancelled ? <span className="badge badge--cancelled">Cancelled</span> : null
|
||||
const draftBadge = !isPaid && !isCancelled && !item.printed
|
||||
? <span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
: null
|
||||
|
||||
// Whole-card click+touch handlers on the outer wrapper
|
||||
const cardHandlers = {
|
||||
onClick: handleBodyClick,
|
||||
onTouchStart: handleTouchStart,
|
||||
onTouchMove: handleTouchMove,
|
||||
onTouchEnd: handleTouchEnd,
|
||||
onTouchCancel: handleTouchEnd,
|
||||
}
|
||||
|
||||
const cardClass = [
|
||||
'order-item',
|
||||
isPaid && isServed ? 'order-item--paid' : '',
|
||||
isCancelled ? 'order-item--cancelled' : '',
|
||||
selectable && selected ? 'order-item--selected' : '',
|
||||
isLast ? 'order-item--last' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
// Indent of secondary rows — lines up with the start of the item name
|
||||
const nameIndent = selectable && !isCancelled ? 44 : 12
|
||||
|
||||
// ── COMPACT ──────────────────────────────────────────────────────────────────
|
||||
const hasPricingRows = priceEvents && priceEvents.some(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
|
||||
const [pricingExpanded, setPricingExpanded] = useState(false)
|
||||
|
||||
if (orderItemLayout === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className={cardClass}
|
||||
style={{ userSelect: 'none', position: 'relative', cursor: canSelect ? 'pointer' : 'default' }}
|
||||
{...cardHandlers}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px' }}>
|
||||
{selectable && !isCancelled && (
|
||||
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{nameEl}
|
||||
<MergedBadge item={item} />
|
||||
<DealBadge item={item} />
|
||||
<PricingBadge events={priceEvents} />
|
||||
{(hasDetails || hasPricingRows) && (
|
||||
<ExpandArrow
|
||||
expanded={expanded || pricingExpanded}
|
||||
onClick={() => {
|
||||
const next = !(expanded || pricingExpanded)
|
||||
if (hasDetails) setExpanded(next)
|
||||
if (hasPricingRows) setPricingExpanded(next)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{paidBadge}
|
||||
{cancelledBadge}
|
||||
{draftBadge}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KdsBadge kdsColor={kdsColor} kdsDisplayStyle={kdsDisplayStyle} />
|
||||
<span className="order-item__qty">{fmtQty(item.quantity, item.unit_type)}</span>
|
||||
<span className="order-item__price" style={hasPriceAdj ? { color: '#60a5fa' } : undefined}>
|
||||
{totalPrice}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(expanded || pricingExpanded) && hasPricingRows && (
|
||||
<PricingRows events={priceEvents} layout="compact" nameIndent={nameIndent} />
|
||||
)}
|
||||
{expanded && hasDetails && <OptionsPanel sections={sections} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EXPANDED ─────────────────────────────────────────────────────────────────
|
||||
if (orderItemLayout === 'expanded') {
|
||||
return (
|
||||
<div
|
||||
className={cardClass}
|
||||
style={{ userSelect: 'none', position: 'relative', cursor: canSelect ? 'pointer' : 'default' }}
|
||||
{...cardHandlers}
|
||||
>
|
||||
{/* Row 1: checkbox · name · badges · expand arrow · KDS badge */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px 2px' }}>
|
||||
{selectable && !isCancelled && (
|
||||
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{nameEl}
|
||||
<DealBadge item={item} />
|
||||
<PricingBadge events={priceEvents} />
|
||||
{(hasDetails || hasPricingRows) && (
|
||||
<ExpandArrow
|
||||
expanded={expanded || pricingExpanded}
|
||||
onClick={() => {
|
||||
const next = !(expanded || pricingExpanded)
|
||||
if (hasDetails) setExpanded(next)
|
||||
if (hasPricingRows) setPricingExpanded(next)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{paidBadge}
|
||||
{cancelledBadge}
|
||||
{draftBadge}
|
||||
</div>
|
||||
<KdsBadge kdsColor={kdsColor} kdsDisplayStyle={kdsDisplayStyle} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: unit price · qty · total */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: `2px 12px ${hasPricingRows && !pricingExpanded ? 2 : 8}px ${nameIndent}px`,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', flex: 1 }}>
|
||||
{hasPriceAdj
|
||||
? <><span style={{ textDecoration: 'line-through', marginRight: 4, opacity: 0.6 }}>{fmtPrice(item.unit_price)}</span><span style={{ color: '#60a5fa' }}>{fmtPrice(unitPriceAdjusted)}</span></>
|
||||
: fmtPrice(unitPriceAdjusted)
|
||||
}
|
||||
</span>
|
||||
<span className="order-item__qty" style={{ textAlign: 'center', minWidth: 48 }}>
|
||||
{fmtQty(item.quantity, item.unit_type)}
|
||||
</span>
|
||||
<span className="order-item__price" style={{ minWidth: 64, textAlign: 'right', ...(hasPriceAdj ? { color: '#60a5fa' } : {}) }}>
|
||||
{totalPrice}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(pricingExpanded || expanded) && hasPricingRows && <PricingRows events={priceEvents} layout="expanded" nameIndent={nameIndent} />}
|
||||
{expanded && hasDetails && <OptionsPanel sections={sections} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── FULLY DETAILED ────────────────────────────────────────────────────────────
|
||||
const addedByName = waiterMap?.[item.added_by] ?? null
|
||||
const paidByName = waiterMap?.[item.paid_by] ?? null
|
||||
const addedAtFmt = fmtDateTime(item.added_at)
|
||||
const paidAtFmt = fmtDateTime(item.paid_at)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
||||
style={{ userSelect: 'none' }}
|
||||
className={cardClass}
|
||||
style={{ userSelect: 'none', position: 'relative', cursor: canSelect ? 'pointer' : 'default' }}
|
||||
{...cardHandlers}
|
||||
>
|
||||
{/* Main row — click to select */}
|
||||
<div
|
||||
onClick={handleBodyClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px',
|
||||
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
{selectable && !isPaid && !isCancelled && (
|
||||
{/* Row 1 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px 2px' }}>
|
||||
{selectable && !isCancelled && (
|
||||
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Name + badges */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||
{!isPaid && !isCancelled && !item.printed && (
|
||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{nameEl}
|
||||
<DealBadge item={item} />
|
||||
<PricingBadge events={priceEvents} />
|
||||
{paidBadge}
|
||||
{cancelledBadge}
|
||||
{draftBadge}
|
||||
</div>
|
||||
|
||||
{/* Qty + price */}
|
||||
<span className="order-item__qty">×{item.quantity}</span>
|
||||
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
||||
|
||||
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
|
||||
{hasDetails && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
|
||||
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<KdsBadge kdsColor={kdsColor} kdsDisplayStyle={kdsDisplayStyle} />
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && hasDetails && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{/* Row 2 — pricing */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: `2px 12px 6px ${nameIndent}px`,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', flex: 1 }}>
|
||||
{hasPriceAdj
|
||||
? <><span style={{ textDecoration: 'line-through', marginRight: 4, opacity: 0.6 }}>{fmtPrice(item.unit_price)}</span><span style={{ color: '#60a5fa' }}>{fmtPrice(unitPriceAdjusted)}</span></>
|
||||
: fmtPrice(unitPriceAdjusted)
|
||||
}
|
||||
</span>
|
||||
<span className="order-item__qty" style={{ textAlign: 'center', minWidth: 48 }}>
|
||||
{fmtQty(item.quantity, item.unit_type)}
|
||||
</span>
|
||||
<span className="order-item__price" style={{ minWidth: 64, textAlign: 'right', ...(hasPriceAdj ? { color: '#60a5fa' } : {}) }}>
|
||||
{totalPrice}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasPricingRows && <PricingRows events={priceEvents} layout="full" nameIndent={nameIndent} />}
|
||||
|
||||
{/* Detail rows */}
|
||||
<div style={{
|
||||
padding: `0 12px 8px ${nameIndent}px`,
|
||||
display: 'flex', flexDirection: 'column', gap: 3,
|
||||
}}>
|
||||
<div style={{ height: 1, background: 'var(--border)', marginBottom: 4 }} />
|
||||
|
||||
{/* Ordered by */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', minWidth: 90 }}>Παραγγέλθηκε</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
|
||||
{addedByName && addedAtFmt ? `${addedByName} · ${addedAtFmt}` : addedByName || addedAtFmt || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Paid by — only if paid */}
|
||||
{isPaid && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', minWidth: 90 }}>Πληρώθηκε</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
|
||||
{paidByName && paidAtFmt ? `${paidByName} · ${paidAtFmt}` : paidByName || paidAtFmt || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options — inline, no expand needed */}
|
||||
{hasDetails && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
|
||||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<SectionIcon type="prefs" />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||
{line.setName && (
|
||||
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||
<span style={{ fontSize: 11, lineHeight: 1.4, flex: 1, color: 'var(--text)' }}>
|
||||
{line.setName && <span style={{ color: 'var(--muted)' }}>{line.setName}: </span>}
|
||||
{line.values.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SectionIcon type="quick" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SectionIcon type="extras" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line.subName && <span> · {line.subName}</span>}
|
||||
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||
@@ -224,34 +645,36 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<SectionIcon type="removed" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<SectionIcon type="note" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem }) {
|
||||
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem, waiterMap, courses = [], priceEventsMap = {} }) {
|
||||
const kdsDisplayStyle = useTableViewStore(s => s.kdsDisplayStyle || 'badge')
|
||||
const orderItemLayout = useTableViewStore(s => s.orderItemLayout || 'compact')
|
||||
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
|
||||
const total = activeItems
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
const paidTotal = activeItems
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
|
||||
return (
|
||||
<div className="order-summary">
|
||||
@@ -265,6 +688,11 @@ export default function OrderSummary({ order, selectable = false, selectedIds =
|
||||
onToggle={onToggle}
|
||||
onLongPress={onLongPressItem}
|
||||
isLast={idx === activeItems.length - 1}
|
||||
kdsDisplayStyle={kdsDisplayStyle}
|
||||
orderItemLayout={orderItemLayout}
|
||||
waiterMap={waiterMap}
|
||||
courses={courses}
|
||||
priceEvents={priceEventsMap[item.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
<div className="order-summary__total">
|
||||
@@ -272,13 +700,13 @@ export default function OrderSummary({ order, selectable = false, selectedIds =
|
||||
<span>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#64748b' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 12px 8px', fontSize: 13, color: '#64748b' }}>
|
||||
<span>Πληρωμένο</span>
|
||||
<span style={{ color: '#22c55e' }}>{fmtPrice(paidTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 12px 8px', fontSize: 13, color: '#94a3b8' }}>
|
||||
<span>Εκκρεμεί</span>
|
||||
<span style={{ color: '#f59e0b', fontWeight: 700 }}>{fmtPrice(total - paidTotal)}</span>
|
||||
</div>
|
||||
|
||||
207
waiter_pwa/src/components/OrderSummaryModal.jsx
Normal file
207
waiter_pwa/src/components/OrderSummaryModal.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const _UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
|
||||
function fmtQty(qty, unitType) {
|
||||
const label = _UNIT_LABELS[unitType]
|
||||
if (!label) return `×${qty}`
|
||||
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
|
||||
return `${qty}${label}`
|
||||
}
|
||||
function fmtEUR(v) { return Number(v || 0).toFixed(2) + ' €' }
|
||||
|
||||
// Flatten selected_options into readable lines
|
||||
function describeOptions(selected_options) {
|
||||
if (!selected_options?.length) return []
|
||||
return selected_options.map(o => {
|
||||
let s = o.name || ''
|
||||
if (o.price_delta && Math.abs(o.price_delta) > 0.001) {
|
||||
s += ` (${o.price_delta > 0 ? '+' : ''}${o.price_delta.toFixed(2)} €)`
|
||||
}
|
||||
return s
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function CartItemSummaryRow({ item, product, onEdit }) {
|
||||
const price = (item.unit_price ?? product?.base_price ?? 0) * item.quantity
|
||||
const opts = describeOptions(item.selected_options)
|
||||
const removed = item.removed_ingredients || []
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: 14, padding: '12px 14px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>{product?.name ?? `#${item.product_id}`}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)', fontWeight: 500 }}>{fmtQty(item.quantity, item.unit_type)}</span>
|
||||
</div>
|
||||
{opts.length > 0 && (
|
||||
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: '3px 8px' }}>
|
||||
{opts.map((o, i) => (
|
||||
<span key={i} style={{ fontSize: 12, color: '#f59e0b', fontWeight: 600, background: 'rgba(245,158,11,0.08)', borderRadius: 6, padding: '1px 6px' }}>{o}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{removed.length > 0 && (
|
||||
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: '3px 8px' }}>
|
||||
{removed.map((r, i) => (
|
||||
<span key={i} style={{ fontSize: 12, color: '#ef4444', fontWeight: 600, background: 'rgba(239,68,68,0.08)', borderRadius: 6, padding: '1px 6px' }}>− {r}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.notes && (
|
||||
<div style={{ marginTop: 5, fontSize: 12, color: 'var(--muted)', fontStyle: 'italic', background: 'var(--bg3)', borderRadius: 7, padding: '4px 8px' }}>
|
||||
"{item.notes}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{fmtEUR(price)}</span>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{ background: 'var(--bg3)', border: '1px solid var(--border)', borderRadius: 7, padding: '3px 10px', fontSize: 12, color: 'var(--muted)', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
Επεξεργασία
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderSummaryModal({ cart, products, orderNote, setOrderNote, onSend, onClose, sending }) {
|
||||
const [noteEditing, setNoteEditing] = useState(false)
|
||||
|
||||
const total = cart.reduce((s, item) => s + (item.unit_price ?? 0) * item.quantity, 0)
|
||||
|
||||
function getProduct(id) { return products.find(p => p.id === id) }
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.65)', zIndex: 200 }}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'var(--bg)',
|
||||
zIndex: 201,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
animation: 'summarySlideUp 260ms cubic-bezier(0.32,0.72,0,1)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 800, color: 'var(--text)' }}>Επισκόπηση Παραγγελίας</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'} · {fmtEUR(total)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'var(--bg3)', border: 'none', borderRadius: '50%', width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text)' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 12px 0' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{cart.map(item => (
|
||||
<CartItemSummaryRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
product={getProduct(item.product_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order note section */}
|
||||
<div style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.7, marginBottom: 8 }}>
|
||||
Σημείωση Παραγγελίας
|
||||
</div>
|
||||
{noteEditing ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={orderNote}
|
||||
onChange={e => setOrderNote(e.target.value)}
|
||||
placeholder="Σημείωση για ολόκληρη την παραγγελία…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%', resize: 'none', padding: '10px 12px',
|
||||
background: 'rgba(245,158,11,0.07)', border: '1.5px solid rgba(245,158,11,0.35)',
|
||||
borderRadius: 10, fontSize: 14, color: 'var(--text)', lineHeight: 1.5,
|
||||
outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setNoteEditing(false)}
|
||||
style={{ alignSelf: 'flex-end', background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: 'var(--accent)', cursor: 'pointer' }}
|
||||
>
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
) : orderNote ? (
|
||||
<div
|
||||
onClick={() => setNoteEditing(true)}
|
||||
style={{ cursor: 'pointer', padding: '10px 13px', background: 'rgba(245,158,11,0.09)', border: '1.5px solid rgba(245,158,11,0.3)', borderRadius: 10, fontSize: 14, color: 'var(--text)', lineHeight: 1.5, whiteSpace: 'pre-wrap', position: 'relative' }}
|
||||
>
|
||||
{orderNote}
|
||||
<span style={{ position: 'absolute', top: 8, right: 10, fontSize: 12, color: '#f59e0b', fontWeight: 700 }}>✎</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setNoteEditing(true)}
|
||||
style={{
|
||||
width: '100%', padding: '10px 13px',
|
||||
background: 'var(--bg2)', border: '1.5px dashed var(--border)',
|
||||
borderRadius: 10, fontSize: 13, color: 'var(--muted)',
|
||||
cursor: 'pointer', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 7,
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
Προσθήκη σημείωσης παραγγελίας
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div style={{ marginTop: 12, marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 4px', borderTop: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Σύνολο</span>
|
||||
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{fmtEUR(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — Send button */}
|
||||
<div style={{ padding: '12px 12px 28px', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%' }}
|
||||
onClick={onSend}
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ (${cart.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes summarySlideUp {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import OrderDrawer from './OrderDrawer'
|
||||
import useWaiterFavoritesStore from '../store/waiterFavoritesStore'
|
||||
|
||||
const FAV_CAT_ID = '__favorites__'
|
||||
const SVC_CAT_ID = '__service__'
|
||||
|
||||
function CategoriesIcon({ width = 20, height = 20 }) {
|
||||
return (
|
||||
@@ -22,35 +27,375 @@ function hexToRgba(hex, alpha) {
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
function ProductGrid({ products, onOpen }) {
|
||||
// A preference set blocks QuickAdd if it has NO default choice set
|
||||
function productAllowsQuickAdd(product) {
|
||||
if (!product.quick_add_enabled) return false
|
||||
const sets = product.preference_sets || []
|
||||
for (const ps of sets) {
|
||||
if (ps.default_choice_id == null) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Decimal unit types (add 1.0 instead of 1)
|
||||
const DECIMAL_UNITS = new Set(['kg', 'liter', 'gram', 'ml'])
|
||||
|
||||
function buildQuickAddItem(product) {
|
||||
const qty = DECIMAL_UNITS.has(product.unit_type) ? 1.0 : 1
|
||||
|
||||
// Auto-resolve preference sets via their default choice
|
||||
const selectedOptions = []
|
||||
for (const ps of (product.preference_sets || [])) {
|
||||
const defaultChoice = ps.choices?.find(c => c.id === ps.default_choice_id)
|
||||
if (defaultChoice) {
|
||||
selectedOptions.push({ id: defaultChoice.id, name: defaultChoice.name, price_delta: defaultChoice.extra_cost ?? 0 })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
product_id: product.id,
|
||||
quantity: qty,
|
||||
unit_type: product.unit_type || 'piece',
|
||||
unit_price: product.base_price,
|
||||
notes: '',
|
||||
selected_options: selectedOptions,
|
||||
removed_ingredients: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Product Detail Modal (long-press) ─────────────────────────────────────────
|
||||
function ProductDetailModal({ product, onClose }) {
|
||||
// Dismiss on Escape
|
||||
useEffect(() => {
|
||||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 301,
|
||||
width: 'min(88vw, 360px)',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute', top: 12, right: 12,
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="#fff" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<div style={{
|
||||
width: '100%', height: 220,
|
||||
background: 'var(--bg2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <span style={{ fontSize: 48, fontWeight: 800, color: 'var(--muted)', letterSpacing: -2 }}>{initials}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding: '18px 20px 24px' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text)', lineHeight: 1.2, marginBottom: 6 }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent, #f59e0b)', marginBottom: product.description ? 14 : 0 }}>
|
||||
{Number(product.base_price).toFixed(2)} €
|
||||
</div>
|
||||
{product.description && (
|
||||
<div style={{
|
||||
fontSize: 14, color: 'var(--muted)', lineHeight: 1.6,
|
||||
borderTop: '1px solid var(--border)', paddingTop: 12,
|
||||
}}>
|
||||
{product.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── useLongPress ──────────────────────────────────────────────────────────────
|
||||
function useLongPress(callback, delay = 2000) {
|
||||
const timerRef = useRef(null)
|
||||
const firedRef = useRef(false)
|
||||
|
||||
const start = useCallback((e) => {
|
||||
firedRef.current = false
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true
|
||||
callback(e)
|
||||
}, delay)
|
||||
}, [callback, delay])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Return true if the long-press fired, so the click handler can skip normal open
|
||||
const didFire = useCallback(() => firedRef.current, [])
|
||||
|
||||
return { handlers: { onPointerDown: start, onPointerUp: cancel, onPointerLeave: cancel, onPointerCancel: cancel }, didFire }
|
||||
}
|
||||
|
||||
// ── Product card ──────────────────────────────────────────────────────────────
|
||||
function ProductCard({ product, onOpen, onQuickAdd, onShowDetail }) {
|
||||
const canQuickAdd = productAllowsQuickAdd(product)
|
||||
|
||||
const onDetail = useCallback(() => onShowDetail(product), [onShowDetail, product])
|
||||
const longPress = useLongPress(onDetail, 1200)
|
||||
|
||||
function handleClick(e) {
|
||||
// If a long-press just fired, don't open the drawer
|
||||
if (longPress.didFire()) return
|
||||
onOpen(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="product-btn"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleClick}
|
||||
{...longPress.handlers}
|
||||
>
|
||||
<div className="product-btn__thumb">
|
||||
<div className="product-btn__thumb-inner">
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" className="product-btn__img" />
|
||||
: <span className="product-btn__initials">
|
||||
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-btn__info">
|
||||
<span className="product-btn__name">{product.name}</span>
|
||||
{product.tags?.length > 0 && (
|
||||
<div className="product-btn__tags">
|
||||
{product.tags.map(tag => (
|
||||
<span key={tag} className="product-tag-pill">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} €</span>
|
||||
</div>
|
||||
|
||||
{canQuickAdd && (
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
width: 80, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<button
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={e => { e.stopPropagation(); onQuickAdd(product) }}
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'var(--bg3)',
|
||||
border: '1.5px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5v14M5 12h14" stroke="var(--accent)" strokeWidth="2.8" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Service Item Card ─────────────────────────────────────────────────────────
|
||||
function ServiceItemCard({ product, customerCount, onAdd }) {
|
||||
const [qty, setQty] = useState(0)
|
||||
|
||||
function dec() { setQty(q => Math.max(0, q - 1)) }
|
||||
function inc() { setQty(q => q + 1) }
|
||||
function setToCustomerCount() { setQty(Math.max(1, customerCount ?? 1)) }
|
||||
|
||||
const hasPrice = product.base_price != null && product.base_price > 0
|
||||
|
||||
const btnBase = {
|
||||
width: 38, height: 38, borderRadius: '50%',
|
||||
border: '1.5px solid var(--border)',
|
||||
cursor: 'pointer', fontSize: 20, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--bg2)',
|
||||
borderRadius: 14,
|
||||
border: '1.5px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
}}>
|
||||
{/* Top row: name + price */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', lineHeight: 1.2, flex: 1, minWidth: 0 }}>
|
||||
{product.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: hasPrice ? '#f59e0b' : 'var(--muted)', flexShrink: 0 }}>
|
||||
{hasPrice ? `${Number(product.base_price).toFixed(2)} €` : 'no charge'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: stepper + count + ADD */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* − */}
|
||||
<button
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={dec}
|
||||
style={{
|
||||
...btnBase,
|
||||
background: qty === 0 ? 'var(--bg3)' : 'rgba(245,158,11,0.15)',
|
||||
color: qty === 0 ? 'var(--muted)' : '#f59e0b',
|
||||
}}
|
||||
>−</button>
|
||||
|
||||
{/* X = set to customer count */}
|
||||
<button
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={setToCustomerCount}
|
||||
title={`Ορισμός σε ${customerCount ?? 1} άτομα`}
|
||||
style={{
|
||||
...btnBase,
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
color: '#f59e0b',
|
||||
fontSize: 14, fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* + */}
|
||||
<button
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={inc}
|
||||
style={{
|
||||
...btnBase,
|
||||
background: 'rgba(245,158,11,0.15)',
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>+</button>
|
||||
|
||||
{/* COUNT indicator */}
|
||||
<span style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontSize: 22, fontWeight: 800,
|
||||
color: qty === 0 ? 'var(--muted)' : 'var(--text)',
|
||||
}}>{qty}</span>
|
||||
|
||||
{/* ADD button */}
|
||||
<button
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (qty === 0) return
|
||||
onAdd({
|
||||
product_id: product.id,
|
||||
quantity: qty,
|
||||
unit_type: 'piece',
|
||||
unit_price: product.base_price ?? 0,
|
||||
notes: '',
|
||||
selected_options: [],
|
||||
removed_ingredients: [],
|
||||
is_service_item: true,
|
||||
}, product)
|
||||
setQty(0)
|
||||
}}
|
||||
disabled={qty === 0}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10,
|
||||
background: qty === 0 ? 'var(--bg3)' : '#f59e0b',
|
||||
border: 'none', cursor: qty === 0 ? 'default' : 'pointer',
|
||||
fontSize: 12, fontWeight: 800,
|
||||
color: qty === 0 ? 'var(--muted)' : '#fff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
ADD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceItemView({ products, customerCount, onAdd }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 14px' }}>
|
||||
{products.length === 0 ? (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: 32 }}>
|
||||
Δεν υπάρχουν service αντικείμενα
|
||||
</p>
|
||||
) : products.map(p => (
|
||||
<ServiceItemCard
|
||||
key={p.id}
|
||||
product={p}
|
||||
customerCount={customerCount}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductGrid({ products, onOpen, onQuickAdd, onShowDetail }) {
|
||||
if (products.length === 0) return null
|
||||
return (
|
||||
<div className="product-grid">
|
||||
{products.map(product => {
|
||||
const initials = product.name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
return (
|
||||
<button key={product.id} className="product-btn" onClick={() => onOpen(product)}>
|
||||
<div className="product-btn__thumb">
|
||||
<div className="product-btn__thumb-inner">
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" className="product-btn__img" />
|
||||
: <span className="product-btn__initials">{initials}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-btn__info">
|
||||
<span className="product-btn__name">{product.name}</span>
|
||||
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} €</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{products.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onOpen={onOpen}
|
||||
onQuickAdd={onQuickAdd}
|
||||
onShowDetail={onShowDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,30 +417,42 @@ function buildSections(parent, subcategories, directProducts) {
|
||||
return sections.sort((a, b) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
export default function ProductPicker({ categories, products, onAdd, viewAllOpen, setViewAllOpen }) {
|
||||
export default function ProductPicker({ categories, products, onAdd, onQuickAdd: onQuickAddProp, viewAllOpen, setViewAllOpen, customerCount, courses = [], globalCourseId = null, quickNotes }) {
|
||||
const navigate = useNavigate()
|
||||
const favorites = useWaiterFavoritesStore(s => s.favorites)
|
||||
|
||||
const serviceProducts = products.filter(p => p.is_service_item)
|
||||
// Service items with a category also appear in that category view
|
||||
const regularProducts = products.filter(p => !p.is_service_item || p.category_id)
|
||||
|
||||
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
||||
const initialCatId = topLevel[0]?.id ?? null
|
||||
// Start on FAVORITES chip if there are any, otherwise first category
|
||||
const initialCatId = favorites.length > 0 ? FAV_CAT_ID : (topLevel[0]?.id ?? null)
|
||||
const [activeCat, setActiveCat] = useState(initialCatId)
|
||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||
const [detailProduct, setDetailProduct] = useState(null)
|
||||
const [modalTab, setModalTab] = useState('categories') // 'categories' | 'tags'
|
||||
const [activeTag, setActiveTag] = useState(null)
|
||||
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
|
||||
const [expandedSubs, setExpandedSubs] = useState(() => {
|
||||
if (!initialCatId) return {}
|
||||
if (!initialCatId || initialCatId === FAV_CAT_ID) return {}
|
||||
const subs = categories.filter(c => c.parent_id === initialCatId)
|
||||
const state = {}
|
||||
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
|
||||
return state
|
||||
})
|
||||
|
||||
const isSvcActive = activeCat === SVC_CAT_ID
|
||||
|
||||
const activeParent = categories.find(c => c.id === activeCat)
|
||||
const subcategories = activeParent
|
||||
? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
|
||||
: []
|
||||
const hasSubcats = subcategories.length > 0
|
||||
|
||||
// Products directly on this top-level category (no sub-cat)
|
||||
const directProducts = products.filter(p => p.category_id === activeCat)
|
||||
const directProducts = regularProducts.filter(p => p.category_id === activeCat)
|
||||
// Products for the flat view (no sub-cats)
|
||||
const flatProducts = products.filter(p => p.category_id === activeCat)
|
||||
const flatProducts = regularProducts.filter(p => p.category_id === activeCat)
|
||||
|
||||
// Build sections for accordion view
|
||||
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
|
||||
@@ -110,7 +467,7 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
function selectCategory(id) {
|
||||
setActiveCat(id)
|
||||
setViewAllOpen(false)
|
||||
setExpandedSubs(buildDefaultExpanded(id))
|
||||
setExpandedSubs(id === FAV_CAT_ID || id === SVC_CAT_ID ? {} : buildDefaultExpanded(id))
|
||||
}
|
||||
|
||||
function toggleSub(key) {
|
||||
@@ -120,11 +477,69 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
function openDrawer(product) { setDrawerProduct(product) }
|
||||
function closeDrawer() { setDrawerProduct(null) }
|
||||
|
||||
function handleQuickAdd(product) {
|
||||
const base = buildQuickAddItem(product)
|
||||
const item = product.is_service_item ? { ...base, is_service_item: true } : base
|
||||
if (onQuickAddProp) {
|
||||
onQuickAddProp(item, product)
|
||||
} else {
|
||||
onAdd(item)
|
||||
}
|
||||
}
|
||||
|
||||
const isFavActive = activeCat === FAV_CAT_ID
|
||||
const favProducts = favorites.map(id => regularProducts.find(p => p.id === id)).filter(Boolean)
|
||||
|
||||
function handleServiceAdd(item, product) {
|
||||
// Service items go into the cart like regular items
|
||||
if (onQuickAddProp) {
|
||||
onQuickAddProp(item, product)
|
||||
} else {
|
||||
onAdd(item)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
<div className="category-tabs__scroll-wrap">
|
||||
<div className="category-tabs__scroll">
|
||||
{/* SERVICE chip — first if service items exist */}
|
||||
{serviceProducts.length > 0 && (
|
||||
<button
|
||||
className="cat-tab"
|
||||
style={{
|
||||
background: isSvcActive ? '#f59e0b' : hexToRgba('#f59e0b', 0.22),
|
||||
color: isSvcActive ? '#fff' : '#b45309',
|
||||
border: isSvcActive ? '2px solid #f59e0b' : undefined,
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}
|
||||
onClick={() => selectCategory(SVC_CAT_ID)}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||
</svg>
|
||||
Service
|
||||
</button>
|
||||
)}
|
||||
{/* FAVORITES chip */}
|
||||
{favorites.length > 0 && (
|
||||
<button
|
||||
className="cat-tab"
|
||||
style={{
|
||||
background: isFavActive ? '#ef4444' : hexToRgba('#ef4444', 0.22),
|
||||
color: isFavActive ? '#fff' : '#ef4444',
|
||||
border: isFavActive ? '2px solid #ef4444' : undefined,
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}
|
||||
onClick={() => selectCategory(FAV_CAT_ID)}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
Αγαπημένα
|
||||
</button>
|
||||
)}
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color
|
||||
@@ -148,12 +563,53 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product area — flat grid or accordion depending on sub-cats */}
|
||||
{/* Product area — service, favorites, flat grid, or accordion depending on sub-cats */}
|
||||
<div className="product-area">
|
||||
{!hasSubcats ? (
|
||||
{isSvcActive ? (
|
||||
// SERVICE view
|
||||
<ServiceItemView
|
||||
products={serviceProducts}
|
||||
customerCount={customerCount}
|
||||
onAdd={handleServiceAdd}
|
||||
/>
|
||||
) : isFavActive ? (
|
||||
// FAVORITES view
|
||||
favProducts.length > 0 ? (
|
||||
<ProductGrid
|
||||
products={favProducts}
|
||||
onOpen={openDrawer}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
onShowDetail={setDetailProduct}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 24px', gap: 14 }}>
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 14, textAlign: 'center', lineHeight: 1.6 }}>
|
||||
Δεν έχεις αγαπημένα ακόμα.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/settings/favorites')}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 12,
|
||||
background: 'var(--accent)', color: 'var(--accent-fg)',
|
||||
border: 'none', fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Ρύθμιση Αγαπημένων
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : !hasSubcats ? (
|
||||
// No sub-categories: original flat grid
|
||||
<>
|
||||
<ProductGrid products={flatProducts} onOpen={openDrawer} />
|
||||
<ProductGrid
|
||||
products={flatProducts}
|
||||
onOpen={openDrawer}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
onShowDetail={setDetailProduct}
|
||||
/>
|
||||
{flatProducts.length === 0 && (
|
||||
<p style={{ color: '#64748b', textAlign: 'center', padding: 32 }}>
|
||||
Δεν υπάρχουν προϊόντα
|
||||
@@ -168,14 +624,19 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
const isOpen = !!expandedSubs[key]
|
||||
const sectionProducts = section._isGeneral
|
||||
? section.products
|
||||
: products.filter(p => p.category_id === section.id)
|
||||
: regularProducts.filter(p => p.category_id === section.id)
|
||||
if (sectionProducts.length === 0) return null
|
||||
|
||||
// General products appear flat — no collapsible header
|
||||
if (section._isGeneral) {
|
||||
return (
|
||||
<div key={key} className="subcat-general">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
<ProductGrid
|
||||
products={sectionProducts}
|
||||
onOpen={openDrawer}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
onShowDetail={setDetailProduct}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -202,7 +663,12 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
|
||||
{isOpen && (
|
||||
<div className="subcat-body">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
<ProductGrid
|
||||
products={sectionProducts}
|
||||
onOpen={openDrawer}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
onShowDetail={setDetailProduct}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,35 +678,143 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All modal — top-level categories only */}
|
||||
{/* View All modal — Κατηγορίες + Tags tabs */}
|
||||
{viewAllOpen && (
|
||||
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
|
||||
<div
|
||||
className="cat-all-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div className="cat-all-modal__header">
|
||||
<span className="cat-all-modal__title">Κατηγορίες</span>
|
||||
<span className="cat-all-modal__title">{modalTab === 'categories' ? 'Κατηγορίες' : 'Tags'}</span>
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="cat-all-grid">
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color || 'var(--bg3)'
|
||||
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
|
||||
<span className="cat-all-tile__name">{cat.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tab strip */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
{['categories', 'tags'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setModalTab(tab)}
|
||||
style={{
|
||||
flex: 1, padding: '10px 0',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
color: modalTab === tab ? 'var(--accent)' : 'var(--muted)',
|
||||
borderBottom: `2px solid ${modalTab === tab ? 'var(--accent)' : 'transparent'}`,
|
||||
marginBottom: -1,
|
||||
transition: 'color 0.12s',
|
||||
}}
|
||||
>
|
||||
{tab === 'categories' ? 'Κατηγορίες' : 'Tags'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modalTab === 'categories' && (
|
||||
<div className="cat-all-grid" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{favorites.length > 0 && (
|
||||
<button
|
||||
className={`cat-all-tile ${isFavActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: '#ef4444', boxShadow: isFavActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(FAV_CAT_ID)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: isFavActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.22)' }} />
|
||||
<span className="cat-all-tile__name" style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="white" stroke="white" strokeWidth="1" style={{ flexShrink: 0 }}>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
Αγαπημένα
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color || 'var(--bg3)'
|
||||
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
|
||||
<span className="cat-all-tile__name">{cat.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalTab === 'tags' && (() => {
|
||||
const allTags = [...new Set(products.flatMap(p => p.tags || []))].sort()
|
||||
const taggedProducts = activeTag ? products.filter(p => (p.tags || []).includes(activeTag)) : []
|
||||
const byCategory = taggedProducts.reduce((acc, p) => {
|
||||
const cat = categories.find(c => c.id === p.category_id)
|
||||
const key = cat?.name || 'Χωρίς Κατηγορία'
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(p)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{allTags.length === 0 && (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: 14 }}>
|
||||
Δεν υπάρχουν tags ακόμα.
|
||||
</div>
|
||||
)}
|
||||
{allTags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setActiveTag(tag === activeTag ? null : tag)}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 20, border: 'none',
|
||||
background: activeTag === tag ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: activeTag === tag ? 'var(--accent-fg)' : 'var(--text)',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTag && taggedProducts.length === 0 && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: 14 }}>
|
||||
Κανένα προϊόν με αυτό το tag.
|
||||
</div>
|
||||
)}
|
||||
{activeTag && Object.entries(byCategory).map(([catName, prods]) => (
|
||||
<div key={catName}>
|
||||
<div style={{ padding: '10px 16px 4px', fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.7 }}>
|
||||
{catName}
|
||||
</div>
|
||||
{prods.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setDrawerProduct(p); setViewAllOpen(false) }}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
width: '100%', padding: '10px 16px',
|
||||
background: 'none', border: 'none', borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)' }}>€{Number(p.base_price).toFixed(2)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -249,8 +823,24 @@ export default function ProductPicker({ categories, products, onAdd, viewAllOpen
|
||||
product={drawerProduct}
|
||||
isOpen={!!drawerProduct}
|
||||
onClose={closeDrawer}
|
||||
onAdd={item => { onAdd(item); closeDrawer() }}
|
||||
onAdd={item => {
|
||||
const finalItem = drawerProduct?.is_service_item
|
||||
? { ...item, is_service_item: true }
|
||||
: item
|
||||
onAdd(finalItem)
|
||||
closeDrawer()
|
||||
}}
|
||||
courses={courses}
|
||||
quickNotes={quickNotes}
|
||||
initialState={globalCourseId != null ? { courseId: globalCourseId } : undefined}
|
||||
/>
|
||||
|
||||
{detailProduct && (
|
||||
<ProductDetailModal
|
||||
product={detailProduct}
|
||||
onClose={() => setDetailProduct(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import useThemeStore from '../store/themeStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
|
||||
function useFlashToggle(active, intervalMs = 900) {
|
||||
const [frame, setFrame] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!active) { setFrame(false); return }
|
||||
const id = setInterval(() => setFrame(f => !f), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [active, intervalMs])
|
||||
return frame
|
||||
}
|
||||
|
||||
// Overlay div that smoothly transitions between two colours (or transparent)
|
||||
function FlashOverlay({ c1, c2, frame }) {
|
||||
const bg = frame ? (c2 || 'transparent') : (c1 || 'transparent')
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 'inherit',
|
||||
background: bg,
|
||||
pointerEvents: 'none', zIndex: 1,
|
||||
transition: 'background 0.45s ease-in-out',
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
const _UNIT_LABELS_TC = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
|
||||
function fmtItemQtyTC(qty, unitType) {
|
||||
const label = _UNIT_LABELS_TC[unitType]
|
||||
if (!label) return `${qty}×`
|
||||
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
|
||||
return `${qty}${label}`
|
||||
}
|
||||
|
||||
|
||||
const STATUS_LABELS = {
|
||||
free: 'ΕΛΕΥΘΕΡΟ',
|
||||
@@ -11,6 +42,49 @@ const STATUS_LABELS = {
|
||||
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
|
||||
}
|
||||
|
||||
// ─── KDS badge helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const kds = s => s || 'pending'
|
||||
|
||||
function computeKdsBadge(order) {
|
||||
if (!order?.items) return null
|
||||
const relevant = order.items.filter(i => i.status !== 'cancelled' && kds(i.kds_status) !== 'served')
|
||||
if (relevant.length === 0) return null
|
||||
const pending = relevant.filter(i => kds(i.kds_status) === 'pending').length
|
||||
const preparing = relevant.filter(i => kds(i.kds_status) === 'preparing').length
|
||||
const done = relevant.filter(i => kds(i.kds_status) === 'done').length
|
||||
const total = relevant.length
|
||||
if (preparing > 0) return { label: 'PREP', color: '#60a5fa', bg: 'rgba(37,99,235,0.18)' }
|
||||
if (done === total) return { label: 'READY', color: '#4ade80', bg: 'rgba(22,163,74,0.20)' }
|
||||
if (done > 0) return { label: `${done}/${total} READY`, color: '#4ade80', bg: 'rgba(22,163,74,0.18)' }
|
||||
return null // all pending — no badge needed
|
||||
}
|
||||
|
||||
// Whether the order has any "done" (ready) items — used for flash overlay
|
||||
function orderHasReadyItems(order) {
|
||||
if (!order?.items) return false
|
||||
return order.items.some(i => i.status !== 'cancelled' && kds(i.kds_status) === 'done')
|
||||
}
|
||||
|
||||
function KdsBadge({ badge, small }) {
|
||||
if (!badge) return null
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: small ? 16 : 20,
|
||||
padding: small ? '0 5px' : '0 8px',
|
||||
borderRadius: 4,
|
||||
background: badge.bg,
|
||||
color: badge.color,
|
||||
fontSize: small ? 8 : 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 0.3,
|
||||
whiteSpace: 'nowrap',
|
||||
border: `1px solid ${badge.color}40`,
|
||||
}}>{badge.label}</span>
|
||||
)
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 8
|
||||
const HOLD_MS = 480
|
||||
|
||||
@@ -206,8 +280,8 @@ function Amount({ value, size = 22, color }) {
|
||||
|
||||
// ─── 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 }) {
|
||||
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, KDS badge bottom (replaces status).
|
||||
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', aspectRatio: '1 / 1.05',
|
||||
@@ -217,8 +291,10 @@ function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
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 }}>
|
||||
{flashOverlay}
|
||||
|
||||
{/* top strip: flags up to 2, then +N */}
|
||||
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, position: 'relative', zIndex: 2 }}>
|
||||
<FlagDots flags={flags} size={16} maxShow={2} />
|
||||
</div>
|
||||
|
||||
@@ -227,27 +303,32 @@ function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{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>
|
||||
{/* bottom strip: KDS badge if present, else status */}
|
||||
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', position: 'relative', zIndex: 2 }}>
|
||||
{kdsBadge ? (
|
||||
<KdsBadge badge={kdsBadge} small />
|
||||
) : (
|
||||
<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 }) {
|
||||
// 2x1 — half width, compact horizontal. Name left, KDS badge (or status) + flags right.
|
||||
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 64,
|
||||
@@ -256,10 +337,14 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 10, overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{flashOverlay}
|
||||
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
@@ -267,8 +352,13 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
{kdsBadge ? (
|
||||
<KdsBadge badge={kdsBadge} small />
|
||||
) : (
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
)}
|
||||
{flags.length > 0 && (
|
||||
<FlagDots flags={flags} size={18} maxShow={3} />
|
||||
)}
|
||||
@@ -277,8 +367,8 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
|
||||
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
// 2x2 — current-style square. Name top-left, status below, amount bottom-left, KDS badge top-right, flags right.
|
||||
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
|
||||
@@ -289,9 +379,12 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
padding: '12px 12px 12px',
|
||||
display: 'flex', gap: 8, overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{flashOverlay}
|
||||
|
||||
{/* left column */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 2 }}>
|
||||
<span style={{
|
||||
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
|
||||
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
|
||||
@@ -307,21 +400,25 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
</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>
|
||||
)}
|
||||
{/* right column: KDS badge top, flags bottom */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
gap: 4, alignItems: 'flex-end', justifyContent: 'space-between',
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{kdsBadge && <KdsBadge badge={kdsBadge} small />}
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 4, alignItems: 'flex-end' }}>
|
||||
<FlagDots flags={flags} size={26} maxShow={2} />
|
||||
</div>
|
||||
)}
|
||||
</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 }) {
|
||||
// 4x1 — full width horizontal. Name + amount left-center, flags, KDS badge (or status) right.
|
||||
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
|
||||
@@ -332,36 +429,48 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
padding: '12px 14px',
|
||||
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{flashOverlay}
|
||||
|
||||
{/* name */}
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
|
||||
{/* separator dot */}
|
||||
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
|
||||
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0, position: 'relative', zIndex: 2 }}>·</span>
|
||||
|
||||
{/* amount — always reserve space, invisible when free */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', visibility: isFree ? 'hidden' : 'visible', position: 'relative', zIndex: 2 }}>
|
||||
<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} />
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<FlagDots flags={flags} size={24} maxShow={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* status */}
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
{/* KDS badge or status */}
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
{kdsBadge ? (
|
||||
<KdsBadge badge={kdsBadge} />
|
||||
) : (
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
)}
|
||||
</div>
|
||||
</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 }) {
|
||||
// 4x2 — full width, tall. Top row: name LEFT | status + KDS badge CENTER (v-centered with amount) | amount RIGHT.
|
||||
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showWaiters = !isFree && waiterObjects.length > 0
|
||||
@@ -373,11 +482,14 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{flashOverlay}
|
||||
|
||||
{/* 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 }}>
|
||||
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', zIndex: 2 }}>
|
||||
{/* top row: name LEFT | badges CENTER | amount RIGHT — vertically centered */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* left: name + zone */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
@@ -397,9 +509,10 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
|
||||
<div style={{ paddingTop: 4, flexShrink: 0 }}>
|
||||
{/* center: status + KDS badge side-by-side, vertically centered */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
{kdsBadge && <KdsBadge badge={kdsBadge} />}
|
||||
</div>
|
||||
|
||||
{/* right: amount — always reserve space, invisible when free */}
|
||||
@@ -429,6 +542,7 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
borderTop: `1px solid ${cfg.nameText}22`,
|
||||
padding: '10px 14px', minHeight: 40,
|
||||
display: 'flex', alignItems: 'center',
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{showWaiters
|
||||
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
|
||||
@@ -439,13 +553,28 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: items. KDS footer. Waiter footer.
|
||||
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey, kdsBadge, flashOverlay }) {
|
||||
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
|
||||
|
||||
// KDS footer summary text
|
||||
const kdsFooter = (() => {
|
||||
if (!order?.items) return null
|
||||
const relevant = order.items.filter(i => i.status !== 'cancelled' && kds(i.kds_status) !== 'served')
|
||||
if (relevant.length === 0) return null
|
||||
const preparing = relevant.filter(i => kds(i.kds_status) === 'preparing').length
|
||||
const done = relevant.filter(i => kds(i.kds_status) === 'done').length
|
||||
const total = relevant.length
|
||||
if (done === total && total > 0) return { text: 'Όλα Έτοιμα για Παράδοση', color: '#4ade80' }
|
||||
const parts = []
|
||||
if (preparing > 0) parts.push(`${preparing} Ετοιμάζεται`)
|
||||
if (done > 0) parts.push(`${done} Έτοιμα`)
|
||||
return parts.length > 0 ? { text: parts.join(' · '), color: done > 0 ? '#4ade80' : '#60a5fa' } : null
|
||||
})()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
@@ -453,8 +582,10 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
|
||||
{flashOverlay}
|
||||
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden', position: 'relative', zIndex: 2 }}>
|
||||
{/* left column: name, zone, amount, status, flags */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
@@ -511,7 +642,7 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
fontSize: 11, fontWeight: 700, color: cfg.nameText,
|
||||
background: `${cfg.nameText}18`, borderRadius: 3,
|
||||
padding: '1px 5px', flexShrink: 0,
|
||||
}}>{item.quantity}×</span>
|
||||
}}>{fmtItemQtyTC(item.quantity, item.unit_type)}</span>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 500, color: cfg.nameText,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
|
||||
@@ -531,11 +662,26 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KDS footer row — only when there's something to show */}
|
||||
{kdsFooter && (
|
||||
<div style={{
|
||||
borderTop: `1px solid ${cfg.nameText}22`,
|
||||
padding: '6px 14px',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 800, color: kdsFooter.color, letterSpacing: 0.3 }}>
|
||||
{kdsFooter.text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* footer: waiters */}
|
||||
<div style={{
|
||||
borderTop: `1px solid ${cfg.nameText}22`,
|
||||
padding: '10px 14px', minHeight: 38,
|
||||
display: 'flex', alignItems: 'center',
|
||||
position: 'relative', zIndex: 2,
|
||||
}}>
|
||||
{showWaiters
|
||||
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
|
||||
@@ -573,8 +719,30 @@ export default function TableCard({
|
||||
else if (order && isMine) statusKey = 'mine'
|
||||
else if (order) statusKey = 'open'
|
||||
|
||||
const mode = dark ? 'dark' : 'light'
|
||||
const cfg = colours[mode][statusKey]
|
||||
const mode = dark ? 'dark' : 'light'
|
||||
const baseCfg = colours[mode][statusKey]
|
||||
const readyCfg = colours[mode]['kds_ready']
|
||||
const kdsBadge = computeKdsBadge(order)
|
||||
const hasReady = orderHasReadyItems(order)
|
||||
const flashOn = hasReady && !!readyCfg?.flash
|
||||
const flashFrame = useFlashToggle(flashOn)
|
||||
|
||||
// 'transparent' in a flash color means "show the base card color for that slot".
|
||||
// The settings UI stores transparent as 'rgba(0,0,0,0)', so we check both forms.
|
||||
const isTransparent = (v) => !v || v === 'transparent' || v === 'rgba(0,0,0,0)'
|
||||
const fc = (flashColor, baseColor) => isTransparent(flashColor) ? baseColor : flashColor
|
||||
|
||||
// cfg always = baseCfg for card background (so table status colour shows through flash overlay)
|
||||
// For text/badge when flashing, alternate between readyCfg set-1 and set-2;
|
||||
// transparent values fall back to the matching baseCfg slot so they're never invisible.
|
||||
const cfg = flashOn
|
||||
? {
|
||||
cardBg: baseCfg.cardBg,
|
||||
badgeBg: fc(flashFrame ? (readyCfg.badgeBg2 ?? readyCfg.badgeBg) : readyCfg.badgeBg, baseCfg.badgeBg),
|
||||
nameText: fc(flashFrame ? (readyCfg.nameText2 ?? readyCfg.nameText) : readyCfg.nameText, baseCfg.nameText),
|
||||
badgeText: fc(flashFrame ? (readyCfg.badgeText2?? readyCfg.badgeText) : readyCfg.badgeText, baseCfg.badgeText),
|
||||
}
|
||||
: baseCfg
|
||||
|
||||
function cancel() {
|
||||
clearTimeout(holdTimer.current)
|
||||
@@ -625,7 +793,10 @@ export default function TableCard({
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
|
||||
const flashOverlay = flashOn
|
||||
? <FlashOverlay c1={readyCfg.cardBg} c2={readyCfg.cardBg2} frame={flashFrame} />
|
||||
: null
|
||||
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey, kdsBadge, flashOverlay }
|
||||
|
||||
const CardComponent = {
|
||||
'1x1': Card1x1,
|
||||
|
||||
48
waiter_pwa/src/components/UpdatePrompt.jsx
Normal file
48
waiter_pwa/src/components/UpdatePrompt.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
export default function UpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW()
|
||||
|
||||
if (!needRefresh) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 80,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 16,
|
||||
padding: '14px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>
|
||||
Νέα έκδοση διαθέσιμη
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: '#0f172a',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Ενημέρωση
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -82,21 +82,27 @@ export default function UserMenu() {
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
title="Μενού χρήστη"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px', borderRadius: 20, border: '2px solid transparent',
|
||||
background: open ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: open ? 'var(--accent-fg)' : 'var(--text)',
|
||||
fontSize: 14, fontWeight: 600, cursor: 'pointer', lineHeight: 1,
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/* Break indicator dot */}
|
||||
{activeBreak && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: 'var(--accent)', flexShrink: 0,
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: open ? 'var(--accent-fg)' : 'var(--accent)', flexShrink: 0,
|
||||
animation: 'tab-pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{user?.username}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<span>{user?.username}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0, opacity: 0.6 }}>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -168,12 +174,6 @@ 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}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useNotificationSettingsStore from '../store/notificationSettingsStore'
|
||||
import client from '../api/client'
|
||||
|
||||
const NotificationContext = createContext(null)
|
||||
@@ -8,28 +9,109 @@ export function useNotifications() {
|
||||
return useContext(NotificationContext)
|
||||
}
|
||||
|
||||
// ─── Persistent banner (one message at a time, stacked) ───────────────────────
|
||||
// ─── Per-type visual config ────────────────────────────────────────────────
|
||||
|
||||
function NotificationBanner({ message, onAck }) {
|
||||
const TYPE_THEME = {
|
||||
manager: {
|
||||
bg: '#1e1b4b', border: '#6366f1', ackBg: '#4f46e5',
|
||||
labelColor: '#a5b4fc', icon: '📢',
|
||||
},
|
||||
kds_order_done: {
|
||||
bg: '#052e16', border: '#22c55e', ackBg: '#16a34a',
|
||||
labelColor: '#86efac', icon: '✅',
|
||||
},
|
||||
kds_item_done: {
|
||||
bg: '#052e16', border: '#4ade80', ackBg: '#15803d',
|
||||
labelColor: '#86efac', icon: '🍽️',
|
||||
},
|
||||
kds_call_order: {
|
||||
bg: '#2d1505', border: '#f97316', ackBg: '#ea580c',
|
||||
labelColor: '#fed7aa', icon: '🔔',
|
||||
},
|
||||
kds_call_general: {
|
||||
bg: '#2d1505', border: '#f59e0b', ackBg: '#d97706',
|
||||
labelColor: '#fde68a', icon: '👋',
|
||||
},
|
||||
}
|
||||
|
||||
function getTheme(messageType) {
|
||||
return TYPE_THEME[messageType] || TYPE_THEME.manager
|
||||
}
|
||||
|
||||
function isManagerMessage(msg) {
|
||||
return msg.message_type === 'manager' || !msg.message_type
|
||||
}
|
||||
|
||||
// ─── Rich body renderer — highlights #order, table name, and zone after " - " ─
|
||||
|
||||
const HIGHLIGHT = { color: '#7dd3fc', fontWeight: 800 } // light blue bold
|
||||
|
||||
function RichBody({ text }) {
|
||||
if (!text) return null
|
||||
// Highlighted tokens:
|
||||
// #digits → order number
|
||||
// τραπέζι <word> → keep "τραπέζι " plain, highlight the word after it
|
||||
// " - " <rest> → keep " - " plain, highlight everything after
|
||||
const parts = []
|
||||
const re = /(#\d+|τραπέζι \S+| - .+$)/g
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) parts.push({ t: 'plain', v: text.slice(last, m.index) })
|
||||
const v = m[0]
|
||||
if (v.startsWith('τραπέζι ')) {
|
||||
parts.push({ t: 'plain', v: 'τραπέζι ' })
|
||||
parts.push({ t: 'hi', v: v.slice(8) })
|
||||
} else if (v.startsWith(' - ')) {
|
||||
parts.push({ t: 'plain', v: ' - ' })
|
||||
parts.push({ t: 'hi', v: v.slice(3) })
|
||||
} else {
|
||||
parts.push({ t: 'hi', v })
|
||||
}
|
||||
last = m.index + v.length
|
||||
}
|
||||
if (last < text.length) parts.push({ t: 'plain', v: text.slice(last) })
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) =>
|
||||
p.t === 'hi'
|
||||
? <span key={i} style={HIGHLIGHT}>{p.v}</span>
|
||||
: <span key={i}>{p.v}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Persistent banner (one message at a time, stacked) ─────────────────────
|
||||
|
||||
function NotificationBanner({ message, onAck, autoDismiss }) {
|
||||
const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
|
||||
const theme = getTheme(message.message_type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoDismiss) return
|
||||
const t = setTimeout(() => onAck(message.id), 3000)
|
||||
return () => clearTimeout(t)
|
||||
}, [message.id, autoDismiss, onAck])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
background: '#1e1b4b', border: '1px solid #6366f1',
|
||||
background: theme.bg, border: `1px solid ${theme.border}`,
|
||||
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>
|
||||
<span style={{ fontSize: 22, flexShrink: 0 }}>{theme.icon}</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}
|
||||
{(message.kds_zone || message.sender_name) && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: theme.labelColor, marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{message.kds_zone || message.sender_name}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
|
||||
{message.body}
|
||||
<RichBody text={message.body} />
|
||||
</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
|
||||
@@ -42,7 +124,7 @@ function NotificationBanner({ message, onAck }) {
|
||||
style={{
|
||||
flexShrink: 0, height: 32, padding: '0 12px',
|
||||
borderRadius: 8, border: 'none',
|
||||
background: '#4f46e5', color: 'white',
|
||||
background: theme.ackBg, color: 'white',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>OK ✓</button>
|
||||
@@ -52,13 +134,22 @@ function NotificationBanner({ message, onAck }) {
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { token, user } = useAuthStore()
|
||||
const { popupMode, persistentPopup } = useNotificationSettingsStore()
|
||||
|
||||
const [pendingMessages, setPendingMessages] = useState([])
|
||||
const [recentMessages, setRecentMessages] = useState([])
|
||||
|
||||
// Messages to show as popup banners (filtered by popupMode)
|
||||
const popupMessages = pendingMessages.filter(msg => {
|
||||
if (popupMode === 'never') return false
|
||||
if (popupMode === 'manager_only') return isManagerMessage(msg)
|
||||
return true
|
||||
})
|
||||
|
||||
const fetchUnread = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/unread')
|
||||
const res = await client.get('/api/notifications/unread')
|
||||
setPendingMessages(res.data)
|
||||
} catch { /* offline or unauthenticated — swallow */ }
|
||||
}, [token, user?.id])
|
||||
@@ -66,7 +157,7 @@ export function NotificationProvider({ children }) {
|
||||
const fetchRecent = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/recent?limit=10')
|
||||
const res = await client.get('/api/notifications/recent?limit=10')
|
||||
setRecentMessages(res.data)
|
||||
} catch { }
|
||||
}, [token, user?.id])
|
||||
@@ -87,7 +178,6 @@ export function NotificationProvider({ children }) {
|
||||
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
|
||||
|
||||
@@ -97,52 +187,68 @@ export function NotificationProvider({ children }) {
|
||||
sender_name: data.sender_name,
|
||||
body: data.body,
|
||||
table_ids: data.table_ids,
|
||||
message_type: data.message_type || 'manager',
|
||||
kds_zone: data.kds_zone || null,
|
||||
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)
|
||||
})
|
||||
|
||||
// popupMode=never + not persistent → ACK immediately (silent delivery, no badge)
|
||||
if (popupMode === 'never' && !persistentPopup) {
|
||||
client.post(`/api/notifications/${msg.id}/ack`).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// All other cases: add to pending so bell badge shows and/or popup renders
|
||||
setPendingMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev]
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('sse-event', onSSEEvent)
|
||||
return () => window.removeEventListener('sse-event', onSSEEvent)
|
||||
}, [user?.id])
|
||||
}, [user?.id, persistentPopup, popupMode])
|
||||
|
||||
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
|
||||
// Fallback: re-fetch unread when SSE reconnects
|
||||
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`)
|
||||
await client.post(`/api/notifications/${messageId}/ack`)
|
||||
setPendingMessages(prev => prev.filter(m => m.id !== messageId))
|
||||
fetchRecent()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Mark all pending as read (used when notification panel opens in never-popup mode)
|
||||
async function ackAll() {
|
||||
const ids = [...pendingMessages.map(m => m.id)]
|
||||
setPendingMessages([])
|
||||
await Promise.allSettled(ids.map(id => client.post(`/api/notifications/${id}/ack`)))
|
||||
fetchRecent()
|
||||
}
|
||||
|
||||
const unreadCount = pendingMessages.length
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, ackAll, fetchRecent, fetchUnread }}>
|
||||
{children}
|
||||
|
||||
{/* Floating banner stack (max 3 visible) */}
|
||||
{pendingMessages.length > 0 && (
|
||||
{/* Floating banner stack — only when popupMode allows */}
|
||||
{popupMessages.length > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
|
||||
padding: '0 12px',
|
||||
@@ -150,17 +256,21 @@ export function NotificationProvider({ children }) {
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
|
||||
{pendingMessages.slice(0, 3).map(msg => (
|
||||
{popupMessages.slice(0, 3).map(msg => (
|
||||
<div key={msg.id} style={{ pointerEvents: 'all' }}>
|
||||
<NotificationBanner message={msg} onAck={ackMessage} />
|
||||
<NotificationBanner
|
||||
message={msg}
|
||||
onAck={ackMessage}
|
||||
autoDismiss={!persistentPopup}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{pendingMessages.length > 3 && (
|
||||
{popupMessages.length > 3 && (
|
||||
<div style={{
|
||||
textAlign: 'center', fontSize: 12, color: '#94a3b8',
|
||||
pointerEvents: 'all',
|
||||
}}>
|
||||
+{pendingMessages.length - 3} ακόμα μηνύματα
|
||||
+{popupMessages.length - 3} ακόμα μηνύματα
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,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 { useWebSocket } from '../hooks/useWebSocket'
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
import { flushOfflinePayments } from '../services/offlinePayments'
|
||||
import { invalidateProductCache } from '../hooks/useProductCache'
|
||||
import { flushAllOfflineOps } from '../services/offlineOrders'
|
||||
import useChatStore from '../store/chatStore'
|
||||
import useKdsReadyStore from '../store/kdsReadyStore'
|
||||
|
||||
const SSEContext = createContext(null)
|
||||
|
||||
@@ -14,37 +15,32 @@ export function useSSEContext() {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000
|
||||
|
||||
export function SSEProvider({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
const { setLost, setOnline } = useConnectionStore()
|
||||
const { setLost, setOnline, setSseAlive } = useConnectionStore()
|
||||
const queryClient = useQueryClient()
|
||||
const sseAlive = useRef(false)
|
||||
const heartbeatRef = useRef(null)
|
||||
const wsAlive = useRef(false)
|
||||
const syncingRef = useRef(false) // prevent concurrent syncs
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────
|
||||
// ── Snapshot helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const snapshotTables = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/tables/')
|
||||
await db.tables.bulkPut(res.data)
|
||||
await db.pos_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 =>
|
||||
res.data.map(o =>
|
||||
client.get(`/api/orders/${o.id}`)
|
||||
.then(r => ({
|
||||
...r.data,
|
||||
@@ -53,36 +49,90 @@ export function SSEProvider({ children }) {
|
||||
.catch(() => o)
|
||||
)
|
||||
)
|
||||
await db.orders.bulkPut(fullOrders)
|
||||
await db.pos_orders.bulkPut(fullOrders)
|
||||
// Purge any IDB orders that are no longer active on the server
|
||||
const activeIds = new Set(fullOrders.map(o => o.id))
|
||||
const allIdbIds = await db.pos_orders.toCollection().primaryKeys()
|
||||
const staleIds = allIdbIds.filter(id => !activeIds.has(id) && typeof id === 'number')
|
||||
if (staleIds.length > 0) await db.pos_orders.bulkDelete(staleIds)
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
useKdsReadyStore.getState().refresh()
|
||||
}, [])
|
||||
|
||||
const snapshotProducts = useCallback(async () => {
|
||||
try {
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
client.get('/api/products/'),
|
||||
client.get('/api/products/categories'),
|
||||
])
|
||||
await db.products.bulkPut(prodRes.data)
|
||||
await db.categories.bulkPut(catRes.data)
|
||||
// Also update React Query cache so AddItemsPage gets it immediately
|
||||
queryClient.setQueryData(['products'], { products: prodRes.data, categories: catRes.data })
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [queryClient])
|
||||
|
||||
const snapshotCatalogue = useCallback(async () => {
|
||||
try {
|
||||
const [groupsRes, flagDefsRes, flagAssignRes] = await Promise.all([
|
||||
client.get('/api/tables/groups'),
|
||||
client.get('/api/flags/defs'),
|
||||
client.get('/api/flags/assignments'),
|
||||
])
|
||||
if (db.table_groups) await db.table_groups.bulkPut(groupsRes.data)
|
||||
if (db.flag_defs) await db.flag_defs.bulkPut(flagDefsRes.data)
|
||||
if (db.flag_assignments) {
|
||||
await db.flag_assignments.clear()
|
||||
if (flagAssignRes.data.length > 0) await db.flag_assignments.bulkAdd(flagAssignRes.data)
|
||||
}
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [])
|
||||
|
||||
const fullRefresh = useCallback(async () => {
|
||||
await Promise.all([snapshotTables(), snapshotOrders()])
|
||||
}, [snapshotTables, snapshotOrders])
|
||||
await Promise.all([snapshotTables(), snapshotOrders(), snapshotCatalogue(), snapshotProducts()])
|
||||
}, [snapshotTables, snapshotOrders, snapshotCatalogue, snapshotProducts])
|
||||
|
||||
// ── SSE event handler ────────────────────────────────────────────────────────
|
||||
// ── Flush all offline queues in strict sequence order ────────────────────────
|
||||
|
||||
const flushOfflineQueue = useCallback(async () => {
|
||||
if (syncingRef.current) return
|
||||
syncingRef.current = true
|
||||
try {
|
||||
await flushAllOfflineOps()
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Called whenever we confirm the server is reachable again ─────────────────
|
||||
|
||||
const handleCameOnline = useCallback(async () => {
|
||||
const wasOffline = useConnectionStore.getState().status !== 'online'
|
||||
setOnlineRef.current()
|
||||
window.dispatchEvent(new Event('sse-reconnected'))
|
||||
if (wasOffline) {
|
||||
await flushOfflineQueue()
|
||||
}
|
||||
await fullRefresh()
|
||||
}, [fullRefresh, flushOfflineQueue])
|
||||
|
||||
// ── 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
|
||||
case 'order_updated': {
|
||||
try {
|
||||
const full = await client.get(`/api/orders/${data.order_id}`)
|
||||
const o = full.data
|
||||
await db.orders.put({
|
||||
await db.pos_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({
|
||||
const existing = await db.pos_orders.get(data.order_id)
|
||||
await db.pos_orders.put({
|
||||
...(existing || {}),
|
||||
id: data.order_id,
|
||||
table_id: data.table_id,
|
||||
@@ -90,47 +140,89 @@ export function SSEProvider({ children }) {
|
||||
waiter_ids: existing?.waiter_ids || [],
|
||||
})
|
||||
}
|
||||
useKdsReadyStore.getState().refresh()
|
||||
break
|
||||
}
|
||||
case 'order_paid': {
|
||||
// Do NOT write to IDB — when auto-close is on, order_closed fires immediately
|
||||
// after order_paid. Writing here races with the delete in order_closed and
|
||||
// can resurrect the order in IDB after it's been deleted.
|
||||
useKdsReadyStore.getState().refresh()
|
||||
break
|
||||
}
|
||||
case 'order_closed': {
|
||||
await db.orders.delete(data.order_id)
|
||||
await db.pos_orders.delete(data.order_id)
|
||||
useKdsReadyStore.getState().refresh()
|
||||
break
|
||||
}
|
||||
case 'kds_item_updated':
|
||||
case 'kds_order_updated': {
|
||||
try {
|
||||
const full = await client.get(`/api/orders/${data.order_id}`)
|
||||
const o = full.data
|
||||
await db.pos_orders.put({
|
||||
...o,
|
||||
waiter_ids: o.waiters?.map(w => w.waiter_id) ?? [],
|
||||
})
|
||||
} catch {}
|
||||
useKdsReadyStore.getState().refresh()
|
||||
break
|
||||
}
|
||||
case 'table_list_changed': {
|
||||
await snapshotTables()
|
||||
break
|
||||
}
|
||||
case 'table_flags_changed': {
|
||||
await snapshotCatalogue()
|
||||
break
|
||||
}
|
||||
case 'products_changed': {
|
||||
invalidateProductCache(queryClient)
|
||||
await snapshotProducts()
|
||||
break
|
||||
}
|
||||
case 'chat_message': {
|
||||
const { conversation_id, message_id, sender_id, sender_name, body, sent_at } = data
|
||||
const { conversations, upsertConversation, incrementUnread } = useChatStore.getState()
|
||||
const existingConv = conversations.find(c => c.id === conversation_id)
|
||||
const newMsg = { id: message_id, conversation_id, sender_id, sender_name, body, sent_at, is_deleted: false }
|
||||
if (existingConv) {
|
||||
upsertConversation({ ...existingConv, last_message: newMsg })
|
||||
if (sender_id !== useAuthStore.getState().user?.id) {
|
||||
incrementUnread(conversation_id)
|
||||
}
|
||||
} else {
|
||||
client.get('/api/chat/conversations')
|
||||
.then(r => useChatStore.getState().setConversations(r.data || []))
|
||||
.catch(() => {})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'chat_read': {
|
||||
useChatStore.getState().updateLastRead(data.conversation_id, data.user_id, data.read_at)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [snapshotTables, queryClient])
|
||||
}, [snapshotTables, snapshotCatalogue, snapshotProducts, queryClient])
|
||||
|
||||
// ── SSE connection lifecycle ─────────────────────────────────────────────────
|
||||
// ── SSE 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])
|
||||
wsAlive.current = true
|
||||
setSseAlive(true)
|
||||
await handleCameOnline()
|
||||
}, [handleCameOnline, setSseAlive])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
sseAlive.current = false
|
||||
// Don't immediately setLost — heartbeat is the authoritative check
|
||||
}, [])
|
||||
wsAlive.current = false
|
||||
setSseAlive(false)
|
||||
// The WS hook will auto-reconnect with exponential backoff.
|
||||
// Don't immediately declare offline — wait for the axios interceptor
|
||||
// or visibility handler to confirm the server is truly unreachable.
|
||||
}, [setSseAlive])
|
||||
|
||||
const { reconnect } = useSSE({
|
||||
const { reconnect } = useWebSocket({
|
||||
token,
|
||||
enabled: !!token,
|
||||
onEvent: handleEvent,
|
||||
@@ -138,75 +230,50 @@ export function SSEProvider({ children }) {
|
||||
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) ───────────────────────
|
||||
// ── Auto-offline on any failed API request ────────────────────────────────────
|
||||
// The axios interceptor fires 'backend-offline' on network errors.
|
||||
// We act on it immediately — no grace, no modals.
|
||||
|
||||
useEffect(() => {
|
||||
function onBackendOffline() {
|
||||
if (!sseAlive.current) {
|
||||
setLostRef.current()
|
||||
}
|
||||
setLostRef.current()
|
||||
}
|
||||
window.addEventListener('backend-offline', onBackendOffline)
|
||||
return () => window.removeEventListener('backend-offline', onBackendOffline)
|
||||
}, [])
|
||||
|
||||
// ── Wake-up handshake — fires when tab/app returns from background ────────────
|
||||
// ── Foreground / visibility handler ──────────────────────────────────────────
|
||||
// When the phone screen turns back on or the user switches back to the tab,
|
||||
// immediately probe the server and pull a full refresh.
|
||||
|
||||
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()
|
||||
if (currentStatus !== 'online') {
|
||||
reconnect()
|
||||
await handleCameOnline()
|
||||
} else if (!wsAlive.current) {
|
||||
// WS dropped silently while sleeping — re-establish and refresh
|
||||
setSseAlive(false)
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
} else if (!sseAlive.current) {
|
||||
// SSE dropped silently while sleeping — re-establish quietly
|
||||
reconnect()
|
||||
} else {
|
||||
// Was online the whole time — still do a data refresh to catch anything missed
|
||||
await fullRefresh()
|
||||
}
|
||||
} catch {
|
||||
if (!sseAlive.current) setLostRef.current()
|
||||
setLostRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [token, reconnect, fullRefresh])
|
||||
}, [token, reconnect, handleCameOnline, fullRefresh])
|
||||
|
||||
// ── Initial snapshot on login ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,23 +1,118 @@
|
||||
import Dexie from 'dexie'
|
||||
|
||||
/**
|
||||
* Local IndexedDB snapshot — written by SSE events and full GETs.
|
||||
* Read-only in Emergency Mode when the server is unreachable.
|
||||
*/
|
||||
// NOTE: do NOT use 'tables' as a store name — Dexie 4.x has a built-in
|
||||
// `.tables` getter on the prototype that returns all registered stores as an
|
||||
// array, so an own-property store named 'tables' gets shadowed by it.
|
||||
// We use pos_ prefix on store names to avoid all such collisions.
|
||||
|
||||
const db = new Dexie('pos_snapshot')
|
||||
|
||||
db.version(1).stores({
|
||||
tables: 'id, group_id, is_active',
|
||||
orders: 'id, table_id, status',
|
||||
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',
|
||||
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',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
})
|
||||
|
||||
db.version(3).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',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
})
|
||||
|
||||
db.version(4).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',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
table_groups: 'id',
|
||||
flag_defs: 'id',
|
||||
flag_assignments: '++localId, table_id, flag_id',
|
||||
})
|
||||
|
||||
// Version 5: rename 'tables' → 'pos_tables' and 'orders' → 'pos_orders'
|
||||
db.version(5).stores({
|
||||
tables: null,
|
||||
orders: null,
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
table_groups: 'id',
|
||||
flag_defs: 'id',
|
||||
flag_assignments: '++localId, table_id, flag_id',
|
||||
pos_tables: 'id, group_id, is_active',
|
||||
pos_orders: 'id, table_id, status',
|
||||
}).upgrade(tx => {
|
||||
return tx.table('tables').toArray().then(rows => {
|
||||
if (rows.length > 0) return tx.table('pos_tables').bulkPut(rows)
|
||||
}).then(() =>
|
||||
tx.table('orders').toArray().then(rows => {
|
||||
if (rows.length > 0) return tx.table('pos_orders').bulkPut(rows)
|
||||
})
|
||||
).catch(() => {})
|
||||
})
|
||||
|
||||
// Version 6: add offline_ops queue for splits and payments taken offline
|
||||
db.version(6).stores({
|
||||
tables: null,
|
||||
orders: null,
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
table_groups: 'id',
|
||||
flag_defs: 'id',
|
||||
flag_assignments: '++localId, table_id, flag_id',
|
||||
pos_tables: 'id, group_id, is_active',
|
||||
pos_orders: 'id, table_id, status',
|
||||
offline_ops: '++localId, orderId, synced, seq',
|
||||
})
|
||||
|
||||
// Version 7: add kv store for persisting WebSocket cursor (last seen seq_id)
|
||||
db.version(7).stores({
|
||||
tables: null,
|
||||
orders: null,
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
table_groups: 'id',
|
||||
flag_defs: 'id',
|
||||
flag_assignments: '++localId, table_id, flag_id',
|
||||
pos_tables: 'id, group_id, is_active',
|
||||
pos_orders: 'id, table_id, status',
|
||||
offline_ops: '++localId, orderId, synced, seq',
|
||||
kv: 'key',
|
||||
})
|
||||
|
||||
// Version 8: add uuid index to offline_ops (needed for legacy-drain dedup check)
|
||||
db.version(8).stores({
|
||||
tables: null,
|
||||
orders: null,
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
offline_orders: '++localId, uuid, synced, table_id',
|
||||
table_groups: 'id',
|
||||
flag_defs: 'id',
|
||||
flag_assignments: '++localId, table_id, flag_id',
|
||||
pos_tables: 'id, group_id, is_active',
|
||||
pos_orders: 'id, table_id, status',
|
||||
offline_ops: '++localId, uuid, orderId, synced, seq',
|
||||
kv: 'key',
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
29
waiter_pwa/src/hooks/useFailedPrintCount.js
Normal file
29
waiter_pwa/src/hooks/useFailedPrintCount.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import client from '../api/client'
|
||||
|
||||
// Polls /api/orders/pending-prints every 8s and returns the count of pending print jobs.
|
||||
export default function useFailedPrintCount() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await client.get('/api/orders/pending-prints')
|
||||
if (!cancelled) setCount(res.data?.count ?? 0)
|
||||
} catch {
|
||||
// Network error — leave count as-is
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
const id = setInterval(refresh, 8000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return count
|
||||
}
|
||||
30
waiter_pwa/src/hooks/useOfflineQueueCount.js
Normal file
30
waiter_pwa/src/hooks/useOfflineQueueCount.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import db from '../db/posdb'
|
||||
|
||||
// Polls offline_ops every 3 s and returns the count of unsynced ops.
|
||||
// Returns 0 when everything is flushed (hides the badge).
|
||||
export default function useOfflineQueueCount() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const ops = await db.offline_ops.toArray()
|
||||
if (!cancelled) setCount(ops.filter(o => !o.synced).length)
|
||||
} catch {
|
||||
// IDB unavailable — leave count as-is
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
const id = setInterval(refresh, 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } 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([
|
||||
@@ -14,7 +13,6 @@ async function fetchAndCacheProducts() {
|
||||
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(() => {})
|
||||
|
||||
@@ -32,38 +30,43 @@ async function loadFromCache() {
|
||||
|
||||
export function useProductCache() {
|
||||
const queryClient = useQueryClient()
|
||||
const [idbData, setIdbData] = useState(null)
|
||||
const [idbLoaded, setIdbLoaded] = useState(false)
|
||||
|
||||
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
|
||||
// Load IDB data immediately on mount — this is the offline fallback
|
||||
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)
|
||||
loadFromCache().then(data => {
|
||||
setIdbData(data)
|
||||
setIdbLoaded(true)
|
||||
// Seed the query cache so React Query uses it as starting data
|
||||
if (data && !queryClient.getQueryData(PRODUCTS_KEY)) {
|
||||
queryClient.setQueryData(PRODUCTS_KEY, data)
|
||||
}
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: PRODUCTS_KEY,
|
||||
queryFn: fetchAndCacheProducts,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
// Don't retry on failure — axios interceptor already fired backend-offline
|
||||
// which switches connection state; we fall back to IDB data instead
|
||||
retry: false,
|
||||
// Only start the query after we've checked IDB, so IDB data is visible first
|
||||
enabled: idbLoaded,
|
||||
})
|
||||
|
||||
// Prefer live query data; fall back to IDB snapshot if query failed or is loading
|
||||
const data = query.data ?? idbData
|
||||
|
||||
return {
|
||||
products: query.data?.products ?? [],
|
||||
categories: query.data?.categories ?? [],
|
||||
isLoading: query.isLoading && !query.data,
|
||||
products: data?.products ?? [],
|
||||
categories: data?.categories ?? [],
|
||||
isLoading: !idbLoaded && query.isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
// Call this from SSEContext when products_changed arrives
|
||||
export function invalidateProductCache(queryClient) {
|
||||
queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
|
||||
}
|
||||
|
||||
159
waiter_pwa/src/hooks/useWebSocket.js
Normal file
159
waiter_pwa/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Persistent WebSocket hook — replaces useSSE.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Connect to ws[s]://host/api/ws/connect?token=<jwt>
|
||||
* 2. Send { cursor: <last_seq> } immediately (0 = first connect)
|
||||
* 3. Receive replayed missed events, then { type: "ready" }
|
||||
* 4. Live events: { seq, type, data }
|
||||
* 5. Keepalive: server sends { type: "ping" } every 25s
|
||||
*
|
||||
* The hook stores `lastSeq` in IndexedDB so it survives page reloads.
|
||||
* On every clean reconnect, missed events are replayed automatically.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import db from '../db/posdb'
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 2_000
|
||||
const MAX_RECONNECT_DELAY = 30_000
|
||||
const CURSOR_KEY = 'ws_last_seq'
|
||||
|
||||
// ── Cursor persistence (IDB) ──────────────────────────────────────────────────
|
||||
|
||||
async function loadCursor() {
|
||||
try {
|
||||
const row = await db.kv?.get(CURSOR_KEY)
|
||||
return row?.value ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCursor(seq) {
|
||||
try {
|
||||
await db.kv?.put({ key: CURSOR_KEY, value: seq })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebSocket({ token, onEvent, onConnect, onDisconnect, enabled = true }) {
|
||||
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 wsRef = useRef(null)
|
||||
const reconnectTimer = useRef(null)
|
||||
const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY)
|
||||
const unmounted = useRef(false)
|
||||
const connectRef = useRef(null)
|
||||
const lastSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !enabled) return
|
||||
unmounted.current = false
|
||||
|
||||
// Load persisted cursor before first connect
|
||||
loadCursor().then(seq => {
|
||||
lastSeq.current = seq
|
||||
if (!unmounted.current) connectRef.current?.()
|
||||
})
|
||||
|
||||
async function connect() {
|
||||
if (unmounted.current) return
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null // prevent double-reconnect
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
// Build WS URL — same host, swap http(s) → ws(s)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${protocol}//${window.location.host}/api/ws/connect?token=${encodeURIComponent(token)}`
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay.current = INITIAL_RECONNECT_DELAY
|
||||
// Send cursor so server can replay missed events
|
||||
ws.send(JSON.stringify({ cursor: lastSeq.current }))
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
let msg
|
||||
try { msg = JSON.parse(e.data) } catch { return }
|
||||
|
||||
if (msg.type === 'ping') return // keepalive — no response needed
|
||||
if (msg.type === 'ready') {
|
||||
// Replay complete — we're now on the live stream
|
||||
onConnectRef.current?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Live or replayed event
|
||||
if (msg.seq && msg.seq > lastSeq.current) {
|
||||
lastSeq.current = msg.seq
|
||||
saveCursor(msg.seq)
|
||||
}
|
||||
if (msg.type && msg.data !== undefined) {
|
||||
onEventRef.current?.(msg.type, msg.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onerror always fires before onclose — let onclose handle reconnect
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
onDisconnectRef.current?.()
|
||||
if (unmounted.current) return
|
||||
// Schedule next attempt with current delay, then increase for the one after
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
reconnectDelay.current = Math.min(
|
||||
reconnectDelay.current * 1.5,
|
||||
MAX_RECONNECT_DELAY,
|
||||
)
|
||||
connect()
|
||||
}, reconnectDelay.current)
|
||||
}
|
||||
}
|
||||
|
||||
connectRef.current = connect
|
||||
|
||||
// When the browser detects network restoration, immediately reset backoff and reconnect.
|
||||
// This fires as soon as the OS reports connectivity — no need to wait for the
|
||||
// backed-off timer or for the user to switch tabs.
|
||||
function onNetworkOnline() {
|
||||
clearTimeout(reconnectTimer.current)
|
||||
reconnectDelay.current = INITIAL_RECONNECT_DELAY
|
||||
connect()
|
||||
}
|
||||
|
||||
window.addEventListener('online', onNetworkOnline)
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
clearTimeout(reconnectTimer.current)
|
||||
window.removeEventListener('online', onNetworkOnline)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [token, enabled])
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
clearTimeout(reconnectTimer.current)
|
||||
reconnectDelay.current = INITIAL_RECONNECT_DELAY
|
||||
connectRef.current?.()
|
||||
}, [])
|
||||
|
||||
return { reconnect }
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
@font-face {
|
||||
font-family: 'Google Sans';
|
||||
src: url('/fonts/GoogleSans-Variable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Google Sans';
|
||||
src: url('/fonts/GoogleSans-Variable-Italic.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* Prevent text selection everywhere — app behaves like native */
|
||||
@@ -17,10 +32,25 @@ input, textarea, [contenteditable] {
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes tab-alert {
|
||||
0% { color: #f87171; }
|
||||
50% { color: #ef444488; }
|
||||
100% { color: #f87171; }
|
||||
}
|
||||
|
||||
@keyframes gate-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Order item text pulse for "done" KDS status in order detail view */
|
||||
@keyframes kds-ready-pulse-text {
|
||||
0%,100% { color: #4ade80; }
|
||||
50% { color: #bbf7d0; }
|
||||
}
|
||||
.kds-ready-flash-text {
|
||||
animation: kds-ready-pulse-text 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* "Free" table card — dark theme: muted blue-slate */
|
||||
--card-free-bg: #243044;
|
||||
@@ -47,7 +77,7 @@ input, textarea, [contenteditable] {
|
||||
--primary-fg: #ffffff;
|
||||
--border: #253245;
|
||||
--shadow: rgba(0,0,0,0.35);
|
||||
font-family: system-ui, 'Segoe UI', sans-serif;
|
||||
font-family: 'Google Sans', system-ui, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
@@ -94,7 +124,7 @@ html, body {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100svh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
@@ -380,7 +410,8 @@ html, body {
|
||||
|
||||
/* ── Product Grid ────────────────────────────────────────── */
|
||||
.product-picker { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.product-area { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
|
||||
.product-area { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; scrollbar-width: none; }
|
||||
.product-area::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Sub-category accordion */
|
||||
.subcat-accordion { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; }
|
||||
@@ -486,14 +517,31 @@ html, body {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
/* always occupy exactly 2 lines */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: calc(1.35em * 2);
|
||||
}
|
||||
.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 4px; }
|
||||
.product-btn__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.product-tag-pill {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Cart Panel ──────────────────────────────────────────── */
|
||||
.cart-panel {
|
||||
@@ -523,14 +571,16 @@ html, body {
|
||||
.order-item--cancelled { opacity: 0.3; text-decoration: line-through; }
|
||||
.order-item--selected { background: rgba(245,158,11,0.10); border-radius: 8px; }
|
||||
.order-item__row { display: flex; align-items: center; gap: 8px; }
|
||||
.order-item__name { flex: 1; font-size: 17px; font-weight: 600; }
|
||||
.order-item__name { flex: 1; font-size: 17px; font-weight: 600; color: #f59e0b; }
|
||||
.order-item__name--served { color: var(--text); }
|
||||
.order-item__name--cancelled { color: var(--muted); }
|
||||
.order-item__qty { font-size: 15px; color: var(--muted); }
|
||||
.order-item__price { font-size: 16px; color: var(--text); font-weight: 600; }
|
||||
.order-item__modifier { font-size: 13px; color: var(--muted); padding-left: 16px; margin-top: 3px; }
|
||||
.order-summary__total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 8px;
|
||||
padding: 16px 12px 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
@@ -633,6 +683,78 @@ html, body {
|
||||
}
|
||||
.qty-value { font-size: 24px; font-weight: 700; min-width: 36px; text-align: center; }
|
||||
|
||||
/* ── Decimal quantity panel (kg / liter / gram / mL products) ── */
|
||||
.qty-decimal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.qty-decimal-input {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
width: 90px;
|
||||
text-align: center;
|
||||
background: var(--bg3);
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
padding: 2px 4px;
|
||||
/* hide spinner arrows */
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.qty-decimal-input::-webkit-outer-spin-button,
|
||||
.qty-decimal-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
|
||||
.qty-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-preset-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.qty-preset-btn--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.qty-offsets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-offset-btn {
|
||||
padding: 7px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--border);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qty-offset-btn--add {
|
||||
background: rgba(34,197,94,0.12);
|
||||
color: var(--success);
|
||||
border-color: rgba(34,197,94,0.3);
|
||||
}
|
||||
.qty-offset-btn--sub {
|
||||
background: rgba(248,113,113,0.12);
|
||||
color: var(--danger);
|
||||
border-color: rgba(248,113,113,0.3);
|
||||
}
|
||||
|
||||
/* ── User Menu Dropdown ──────────────────────────────────── */
|
||||
.user-menu-dropdown {
|
||||
position: absolute;
|
||||
@@ -680,3 +802,5 @@ html, body {
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@keyframes longpress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
|
||||
@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import db from './db/posdb.js'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
// Ensure the IndexedDB schema is fully open before any component mounts.
|
||||
// Without this, table accessors (db.tables, db.orders, etc.) can be undefined
|
||||
// when components try to use them synchronously on first render.
|
||||
db.open().catch(err => console.error('[IDB] open failed:', err)).finally(() => {
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
468
waiter_pwa/src/pages/ChatListPage.jsx
Normal file
468
waiter_pwa/src/pages/ChatListPage.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useChatStore from '../store/chatStore'
|
||||
|
||||
// ─── Relative time (Greek) ────────────────────────────────────────────────────
|
||||
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
const diffHours = Math.floor(diffMs / 3_600_000)
|
||||
|
||||
if (diffMin < 1) return 'τώρα'
|
||||
if (diffMin < 60) return `${diffMin}λ`
|
||||
if (diffHours < 24) return `${diffHours}ω`
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
) return 'χθες'
|
||||
|
||||
return date.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
// ─── Avatar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Avatar({ label, size = 44, isGroup = false }) {
|
||||
const initials = isGroup
|
||||
? '👥'
|
||||
: (label || '?').slice(0, 2).toUpperCase()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
background: isGroup ? 'var(--bg3)' : 'var(--accent)',
|
||||
color: isGroup ? 'var(--text)' : 'var(--accent-fg)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: isGroup ? 20 : 16, fontWeight: 700,
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Conversation row ─────────────────────────────────────────────────────────
|
||||
|
||||
function ConvRow({ conv, currentUserId, onClick }) {
|
||||
const isGroup = conv.type === 'group'
|
||||
const isDirect = conv.type === 'direct'
|
||||
|
||||
const title = isGroup
|
||||
? (conv.name || 'Ομάδα')
|
||||
: (conv.participants?.find(p => p.user_id !== currentUserId)?.username || 'Άγνωστος')
|
||||
|
||||
const lastMsg = conv.last_message
|
||||
const preview = lastMsg
|
||||
? lastMsg.is_deleted
|
||||
? 'Το μήνυμα διαγράφηκε'
|
||||
: lastMsg.body
|
||||
: 'Δεν υπάρχουν μηνύματα'
|
||||
|
||||
const timestamp = lastMsg?.sent_at || conv.created_at
|
||||
const hasUnread = (conv.unread_count || 0) > 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||
background: 'none', border: 'none', borderBottom: '1px solid var(--border)',
|
||||
width: '100%', textAlign: 'left', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Avatar label={isGroup ? null : title} isGroup={isGroup} />
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 15, fontWeight: hasUnread ? 700 : 600,
|
||||
color: 'var(--text)', flex: 1,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', flexShrink: 0 }}>
|
||||
{relativeTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13, color: 'var(--muted)', marginTop: 2,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontStyle: lastMsg?.is_deleted ? 'italic' : 'normal',
|
||||
fontWeight: hasUnread ? 600 : 400,
|
||||
}}>
|
||||
{isGroup && lastMsg && !lastMsg.is_deleted && lastMsg.sender_name
|
||||
? `${lastMsg.sender_name}: ${preview}`
|
||||
: preview
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasUnread && (
|
||||
<div style={{
|
||||
background: 'var(--danger)', color: '#fff',
|
||||
borderRadius: '50%', minWidth: 20, height: 20,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 800, flexShrink: 0,
|
||||
padding: conv.unread_count > 9 ? '0 6px' : '0',
|
||||
}}>
|
||||
{conv.unread_count > 99 ? '99+' : conv.unread_count}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── New Chat bottom drawer ───────────────────────────────────────────────────
|
||||
|
||||
function NewChatDrawer({ currentUserId, onClose, onConversationCreated }) {
|
||||
const [staff, setStaff] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [mode, setMode] = useState('picker') // 'picker' | 'group_name' | 'group_pick'
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/chat/users')
|
||||
.then(r => setStaff((r.data || []).filter(u => u.id !== currentUserId)))
|
||||
.catch(() => setError('Αποτυχία φόρτωσης χρηστών'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [currentUserId])
|
||||
|
||||
async function startDirect(userId) {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.post('/api/chat/conversations', {
|
||||
type: 'direct',
|
||||
participant_ids: [userId],
|
||||
})
|
||||
onConversationCreated(res.data)
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.detail || 'Σφάλμα δημιουργίας συνομιλίας')
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
if (!groupName.trim()) return
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.post('/api/chat/conversations', {
|
||||
type: 'group',
|
||||
name: groupName.trim(),
|
||||
participant_ids: selectedIds,
|
||||
})
|
||||
onConversationCreated(res.data)
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.detail || 'Σφάλμα δημιουργίας ομάδας')
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||
<div className="modal-handle" />
|
||||
|
||||
{mode === 'picker' && (
|
||||
<>
|
||||
<h2 className="modal-title" style={{ marginBottom: 16 }}>Νέα Συνομιλία</h2>
|
||||
|
||||
{/* New Group option */}
|
||||
<button
|
||||
onClick={() => setMode('group_name')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 4px', background: 'none', border: 'none',
|
||||
borderBottom: '1px solid var(--border)', width: '100%',
|
||||
cursor: 'pointer', textAlign: 'left', marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontSize: 20,
|
||||
}}>
|
||||
👥
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--accent)' }}>Νέα Ομάδα</span>
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', marginTop: 4 }}>
|
||||
{loading && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 24, fontSize: 14 }}>
|
||||
Φόρτωση…
|
||||
</p>
|
||||
)}
|
||||
{!loading && staff.length === 0 && !error && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 24, fontSize: 14 }}>
|
||||
Δεν υπάρχει άλλο προσωπικό
|
||||
</p>
|
||||
)}
|
||||
{staff.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => startDirect(u.id)}
|
||||
disabled={creating}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 4px', background: 'none', border: 'none',
|
||||
borderBottom: '1px solid var(--border)', width: '100%',
|
||||
cursor: creating ? 'not-allowed' : 'pointer', textAlign: 'left',
|
||||
opacity: creating ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Avatar label={u.username || u.full_name} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
|
||||
{u.full_name || u.username}
|
||||
</div>
|
||||
{u.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{u.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'group_name' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<button
|
||||
onClick={() => setMode('picker')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text)', padding: 0 }}
|
||||
>
|
||||
<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>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Όνομα Ομάδας</h2>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={groupName}
|
||||
onChange={e => setGroupName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && groupName.trim() && setMode('group_pick')}
|
||||
placeholder="π.χ. Βραδινή βάρδια"
|
||||
style={{
|
||||
width: '100%', padding: '12px 14px',
|
||||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, color: 'var(--text)', fontSize: 16, outline: 'none',
|
||||
boxSizing: 'border-box', marginBottom: 16,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ width: '100%' }}
|
||||
disabled={!groupName.trim()}
|
||||
onClick={() => groupName.trim() && setMode('group_pick')}
|
||||
>
|
||||
Επόμενο
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'group_pick' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={() => setMode('group_name')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text)', padding: 0 }}
|
||||
>
|
||||
<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>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Επιλογή Μελών</h2>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
Ομάδα: <strong style={{ color: 'var(--text)' }}>{groupName}</strong>
|
||||
</p>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', marginBottom: 12 }}>
|
||||
{staff.map(u => {
|
||||
const selected = selectedIds.includes(u.id)
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggleSelect(u.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '11px 4px', background: 'none', border: 'none',
|
||||
borderBottom: '1px solid var(--border)', width: '100%',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: selected ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.12s, border-color 0.12s',
|
||||
}}>
|
||||
{selected && (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 7l3 3 6-6" stroke="var(--accent-fg)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<Avatar label={u.username || u.full_name} size={40} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
|
||||
{u.full_name || u.username}
|
||||
</div>
|
||||
{u.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{u.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn"
|
||||
style={{ width: '100%' }}
|
||||
disabled={creating}
|
||||
onClick={createGroup}
|
||||
>
|
||||
{creating ? 'Δημιουργία…' : `Δημιουργία Ομάδας${selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 10, textAlign: 'center' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChatListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const { conversations, setConversations, upsertConversation } = useChatStore()
|
||||
const [showNewChat, setShowNewChat] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// ── Load conversations ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
client.get('/api/chat/conversations')
|
||||
.then(r => setConversations(r.data || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
|
||||
function handleConversationCreated(conv) {
|
||||
upsertConversation(conv)
|
||||
setShowNewChat(false)
|
||||
navigate(`/messages/${conv.id}`)
|
||||
}
|
||||
|
||||
// Separate system group (pinned) from regular conversations
|
||||
const systemConv = conversations.find(c => c.is_system)
|
||||
const regularConvs = conversations.filter(c => !c.is_system)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Conversation list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
|
||||
{/* ── New conversation row (always first) ─────────────────────── */}
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '14px 16px', width: '100%',
|
||||
background: 'none', border: 'none', borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="var(--accent-fg)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="var(--accent-fg)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--accent)' }}>
|
||||
Νέα Συνομιλία
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* ── System group (pinned second) ────────────────────────────── */}
|
||||
{systemConv && (
|
||||
<ConvRow
|
||||
conv={systemConv}
|
||||
currentUserId={user?.id}
|
||||
onClick={() => navigate(`/messages/${systemConv.id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Regular conversations ────────────────────────────────────── */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)', fontSize: 14 }}>
|
||||
Φόρτωση…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && regularConvs.length === 0 && !systemConv && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flex: 1, padding: 40, gap: 12, minHeight: 200,
|
||||
}}>
|
||||
<span style={{ fontSize: 40 }}>💬</span>
|
||||
<p style={{ fontSize: 15, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.5, margin: 0 }}>
|
||||
Πατήστε «Νέα Συνομιλία» για να ξεκινήσετε μια συζήτηση.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && regularConvs.map(conv => (
|
||||
<ConvRow
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
currentUserId={user?.id}
|
||||
onClick={() => navigate(`/messages/${conv.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showNewChat && (
|
||||
<NewChatDrawer
|
||||
currentUserId={user?.id}
|
||||
onClose={() => setShowNewChat(false)}
|
||||
onConversationCreated={handleConversationCreated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
573
waiter_pwa/src/pages/ChatThreadPage.jsx
Normal file
573
waiter_pwa/src/pages/ChatThreadPage.jsx
Normal file
@@ -0,0 +1,573 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useChatStore from '../store/chatStore'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
if (d.getTime() === today.getTime()) return 'Σήμερα'
|
||||
if (d.getTime() === yesterday.getTime()) return 'Χθες'
|
||||
return date.toLocaleDateString('el-GR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
const da = new Date(a), db = new Date(b)
|
||||
return da.getFullYear() === db.getFullYear() &&
|
||||
da.getMonth() === db.getMonth() &&
|
||||
da.getDate() === db.getDate()
|
||||
}
|
||||
|
||||
// ─── Message bubble ───────────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg, isOwn, showSender, isGroup }) {
|
||||
if (msg.is_deleted) {
|
||||
return (
|
||||
<div style={{
|
||||
alignSelf: isOwn ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '75%',
|
||||
padding: '8px 14px',
|
||||
borderRadius: isOwn ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
background: 'var(--bg3)',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
Το μήνυμα διαγράφηκε
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
alignSelf: isOwn ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '75%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: isOwn ? 'flex-end' : 'flex-start',
|
||||
gap: 3,
|
||||
}}>
|
||||
{isGroup && showSender && !isOwn && (
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', paddingLeft: 4 }}>
|
||||
{msg.sender_name}
|
||||
</span>
|
||||
)}
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: isOwn ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
background: isOwn ? 'var(--accent)' : 'var(--bg2)',
|
||||
color: isOwn ? 'var(--accent-fg)' : 'var(--text)',
|
||||
fontSize: 15, lineHeight: 1.45, wordBreak: 'break-word',
|
||||
}}>
|
||||
{msg.body}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', paddingLeft: 2, paddingRight: 2 }}>
|
||||
{formatTime(msg.sent_at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Date separator ───────────────────────────────────────────────────────────
|
||||
|
||||
function DateSeparator({ label }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
margin: '16px 0 8px',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--muted)',
|
||||
letterSpacing: 0.5, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Auto-resize textarea ─────────────────────────────────────────────────────
|
||||
|
||||
function AutoTextarea({ value, onChange, onSubmit, placeholder }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
ref.current.style.height = 'auto'
|
||||
const scrollH = ref.current.scrollHeight
|
||||
const lineH = 22 // approx line height
|
||||
const maxH = lineH * 3 + 16 // 3 lines + padding
|
||||
ref.current.style.height = Math.min(scrollH, maxH) + 'px'
|
||||
}, [value])
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, resize: 'none', background: 'var(--bg3)',
|
||||
border: '1px solid var(--border)', borderRadius: 22,
|
||||
padding: '10px 16px', color: 'var(--text)', fontSize: 15,
|
||||
outline: 'none', lineHeight: 1.4, fontFamily: 'inherit',
|
||||
maxHeight: 82, overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Leave confirmation modal ─────────────────────────────────────────────────
|
||||
|
||||
function LeaveModal({ onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="modal-sheet"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxHeight: 'auto' }}
|
||||
>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title" style={{ marginBottom: 8 }}>Αποχώρηση από ομάδα;</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 20 }}>
|
||||
Δεν θα λαμβάνετε πλέον μηνύματα από αυτή την ομάδα.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onCancel}>Ακύρωση</button>
|
||||
<button
|
||||
style={{
|
||||
flex: 1, height: 44, borderRadius: 12, border: 'none',
|
||||
background: 'var(--danger)', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Αποχώρηση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChatThreadPage() {
|
||||
const { conversationId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
const {
|
||||
conversations, messages: allMessages,
|
||||
upsertConversation, markConvRead, addMessage,
|
||||
setMessages, prependMessages, updateLastRead,
|
||||
} = useChatStore()
|
||||
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [showLeave, setShowLeave] = useState(false)
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
|
||||
const messagesEl = useRef(null)
|
||||
const bottomAnchor = useRef(null)
|
||||
const offsetRef = useRef(0)
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const conv = conversations.find(c => c.id === conversationId)
|
||||
const rawMessages = allMessages[conversationId] || []
|
||||
// API returns newest-first, we display oldest-first
|
||||
const displayMessages = [...rawMessages].reverse()
|
||||
|
||||
const isGroup = conv?.type === 'group'
|
||||
const isDirect = conv?.type === 'direct'
|
||||
|
||||
const title = isGroup
|
||||
? (conv?.name || 'Ομάδα')
|
||||
: (conv?.participants?.find(p => p.user_id !== user?.id)?.username || 'Συνομιλία')
|
||||
|
||||
// ── Mark as read ─────────────────────────────────────────────────────────────
|
||||
|
||||
const markRead = useCallback(async () => {
|
||||
try {
|
||||
await client.post(`/api/chat/conversations/${conversationId}/read`)
|
||||
markConvRead(conversationId)
|
||||
} catch {}
|
||||
}, [conversationId, markConvRead])
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
// Load conversation metadata if we don't have it
|
||||
if (!conv) {
|
||||
try {
|
||||
const r = await client.get(`/api/chat/conversations/${conversationId}`)
|
||||
if (!cancelled) upsertConversation(r.data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Load first page of messages
|
||||
try {
|
||||
const r = await client.get(
|
||||
`/api/chat/conversations/${conversationId}/messages?offset=0&limit=${PAGE_SIZE}`
|
||||
)
|
||||
if (!cancelled) {
|
||||
const msgs = r.data || []
|
||||
setMessages(conversationId, msgs)
|
||||
offsetRef.current = msgs.length
|
||||
setHasMore(msgs.length === PAGE_SIZE)
|
||||
setInitialLoad(false)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setInitialLoad(false)
|
||||
}
|
||||
|
||||
markRead()
|
||||
}
|
||||
|
||||
init()
|
||||
return () => { cancelled = true }
|
||||
}, [conversationId])
|
||||
|
||||
// ── Scroll to bottom after initial load ───────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoad && bottomAnchor.current) {
|
||||
bottomAnchor.current.scrollIntoView({ behavior: 'instant' })
|
||||
}
|
||||
}, [initialLoad])
|
||||
|
||||
// ── Infinite scroll upward ────────────────────────────────────────────────────
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || !hasMore) return
|
||||
const el = messagesEl.current
|
||||
const prevScrollHeight = el?.scrollHeight || 0
|
||||
const prevScrollTop = el?.scrollTop || 0
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const r = await client.get(
|
||||
`/api/chat/conversations/${conversationId}/messages?offset=${offsetRef.current}&limit=${PAGE_SIZE}`
|
||||
)
|
||||
const older = r.data || []
|
||||
prependMessages(conversationId, older)
|
||||
offsetRef.current += older.length
|
||||
setHasMore(older.length === PAGE_SIZE)
|
||||
|
||||
// Restore scroll position after prepend
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight - prevScrollHeight + prevScrollTop
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
function handleScroll(e) {
|
||||
if (e.target.scrollTop < 60 && hasMore && !loadingMore) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE wiring ────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
function onSSE(e) {
|
||||
const { type, data } = e.detail
|
||||
// conversationId from useParams is a string; SSE payload has an integer
|
||||
if (type === 'chat_message' && String(data.conversation_id) === conversationId) {
|
||||
// Own messages are handled optimistically in sendMessage — skip the SSE echo
|
||||
// to avoid showing the message twice before the optimistic replace completes.
|
||||
if (data.sender_id === user?.id) return
|
||||
const msg = {
|
||||
id: data.message_id,
|
||||
conversation_id: data.conversation_id,
|
||||
sender_id: data.sender_id,
|
||||
sender_name: data.sender_name,
|
||||
body: data.body,
|
||||
sent_at: data.sent_at,
|
||||
is_deleted: false,
|
||||
}
|
||||
addMessage(conversationId, msg)
|
||||
offsetRef.current += 1
|
||||
markRead()
|
||||
// Scroll to bottom if near bottom
|
||||
const el = messagesEl.current
|
||||
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 120) {
|
||||
requestAnimationFrame(() => {
|
||||
bottomAnchor.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
// Update last_message in conv list
|
||||
const existingConv = useChatStore.getState().conversations.find(c => c.id === conversationId)
|
||||
if (existingConv) {
|
||||
upsertConversation({
|
||||
...existingConv,
|
||||
last_message: { ...msg },
|
||||
})
|
||||
}
|
||||
} else if (type === 'chat_read' && String(data.conversation_id) === conversationId) {
|
||||
updateLastRead(data.conversation_id, data.user_id, data.read_at)
|
||||
}
|
||||
}
|
||||
window.addEventListener('sse-event', onSSE)
|
||||
return () => window.removeEventListener('sse-event', onSSE)
|
||||
}, [conversationId, addMessage, markRead, upsertConversation, updateLastRead])
|
||||
|
||||
// ── Send message ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
const body = inputText.trim()
|
||||
if (!body || sending) return
|
||||
setInputText('')
|
||||
setSending(true)
|
||||
|
||||
// Optimistic
|
||||
const optimisticMsg = {
|
||||
id: `opt-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
sender_id: user?.id,
|
||||
sender_name: user?.username,
|
||||
body,
|
||||
sent_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
_optimistic: true,
|
||||
}
|
||||
addMessage(conversationId, optimisticMsg)
|
||||
offsetRef.current += 1
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
bottomAnchor.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
|
||||
try {
|
||||
const r = await client.post(`/api/chat/conversations/${conversationId}/messages`, { body })
|
||||
// Replace optimistic with real
|
||||
useChatStore.setState(state => {
|
||||
const msgs = (state.messages[conversationId] || []).map(m =>
|
||||
m.id === optimisticMsg.id ? r.data : m
|
||||
)
|
||||
return { messages: { ...state.messages, [conversationId]: msgs } }
|
||||
})
|
||||
const existingConv = useChatStore.getState().conversations.find(c => c.id === conversationId)
|
||||
if (existingConv) {
|
||||
upsertConversation({ ...existingConv, last_message: r.data })
|
||||
}
|
||||
} catch {
|
||||
// Remove optimistic on failure, restore input
|
||||
useChatStore.setState(state => {
|
||||
const msgs = (state.messages[conversationId] || []).filter(m => m.id !== optimisticMsg.id)
|
||||
return { messages: { ...state.messages, [conversationId]: msgs } }
|
||||
})
|
||||
setInputText(body)
|
||||
offsetRef.current = Math.max(0, offsetRef.current - 1)
|
||||
}
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
// ── Leave group ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function leaveGroup() {
|
||||
try {
|
||||
await client.post(`/api/chat/conversations/${conversationId}/leave`)
|
||||
useChatStore.setState(state => ({
|
||||
conversations: state.conversations.filter(c => c.id !== conversationId),
|
||||
totalUnread: state.conversations
|
||||
.filter(c => c.id !== conversationId)
|
||||
.reduce((s, c) => s + (c.unread_count || 0), 0),
|
||||
}))
|
||||
navigate('/messages', { replace: true })
|
||||
} catch {}
|
||||
setShowLeave(false)
|
||||
}
|
||||
|
||||
// ── Render messages with date separators ──────────────────────────────────────
|
||||
|
||||
function renderMessages() {
|
||||
const items = []
|
||||
for (let i = 0; i < displayMessages.length; i++) {
|
||||
const msg = displayMessages[i]
|
||||
const prev = displayMessages[i - 1]
|
||||
|
||||
if (!prev || !sameDay(prev.sent_at, msg.sent_at)) {
|
||||
items.push(
|
||||
<DateSeparator key={`date-${msg.id}`} label={formatDateLabel(msg.sent_at)} />
|
||||
)
|
||||
}
|
||||
|
||||
const isOwn = msg.sender_id === user?.id
|
||||
// Show sender name on every incoming group message
|
||||
const showSender = isGroup && !isOwn
|
||||
|
||||
items.push(
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
isOwn={isOwn}
|
||||
showSender={showSender}
|
||||
isGroup={isGroup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const canLeave = isGroup && !conv?.is_system
|
||||
|
||||
return (
|
||||
<div className="page" style={{ background: 'var(--bg)' }}>
|
||||
{/* Top bar */}
|
||||
<header className="top-bar">
|
||||
<button
|
||||
onClick={() => navigate('/messages')}
|
||||
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,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div style={{ flex: 1, textAlign: 'center', minWidth: 0 }}>
|
||||
<span className="top-bar__title" style={{
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
{isGroup && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', display: 'block', marginTop: -2 }}>
|
||||
{(conv?.participants || []).length} μέλη
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canLeave ? (
|
||||
<button
|
||||
onClick={() => setShowLeave(true)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--danger)', fontSize: 13, fontWeight: 700,
|
||||
padding: '0 4px', minHeight: 44, borderRadius: 8, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Αποχώρηση
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ width: 72, flexShrink: 0 }} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={messagesEl}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
flex: 1, overflowY: 'auto', minHeight: 0,
|
||||
padding: '12px 16px',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
>
|
||||
{loadingMore && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0', color: 'var(--muted)', fontSize: 13 }}>
|
||||
Φόρτωση παλαιότερων…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{initialLoad && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 14 }}>Φόρτωση…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialLoad && displayMessages.length === 0 && (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 40 }}>💬</span>
|
||||
<p style={{ fontSize: 15, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
Δεν υπάρχουν μηνύματα ακόμα.{'\n'}Πείτε γεια!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialLoad && renderMessages()}
|
||||
|
||||
<div ref={bottomAnchor} />
|
||||
</div>
|
||||
|
||||
{/* Send bar */}
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderTop: '1px solid var(--border)',
|
||||
padding: '10px 12px',
|
||||
display: 'flex', alignItems: 'flex-end', gap: 10,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<AutoTextarea
|
||||
value={inputText}
|
||||
onChange={e => setInputText(e.target.value)}
|
||||
onSubmit={sendMessage}
|
||||
placeholder="Γράψτε μήνυμα…"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!inputText.trim() || sending}
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: '50%', border: 'none',
|
||||
background: inputText.trim() ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: inputText.trim() ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
cursor: inputText.trim() ? 'pointer' : 'not-allowed',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 2L11 13" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLeave && (
|
||||
<LeaveModal onConfirm={leaveGroup} onCancel={() => setShowLeave(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
512
waiter_pwa/src/pages/FavoritesSetupPage.jsx
Normal file
512
waiter_pwa/src/pages/FavoritesSetupPage.jsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useProductCache } from '../hooks/useProductCache'
|
||||
import useWaiterFavoritesStore from '../store/waiterFavoritesStore'
|
||||
|
||||
// ── Heart icon ────────────────────────────────────────────────────────────────
|
||||
function Heart({ filled, size = 22 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? '#ef4444' : 'none'} stroke={filled ? '#ef4444' : 'currentColor'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Thumb (product image or initials) ─────────────────────────────────────────
|
||||
function Thumb({ product, size = 42, radius = 10 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: radius, flexShrink: 0,
|
||||
background: 'var(--bg3)', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <span style={{ fontSize: Math.round(size * 0.33), fontWeight: 700, color: 'var(--muted)' }}>
|
||||
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Item Favorites Modal ───────────────────────────────────────────────────────
|
||||
// Shows all attribute sections with heart toggles.
|
||||
// The waiter picks which items appear in that product's Favorites tab.
|
||||
|
||||
function AttrSection({ title, items, favIds, onToggle }) {
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--muted)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
padding: '0 0 8px',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{items.map(item => {
|
||||
const isFav = favIds.includes(item.id)
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px',
|
||||
background: isFav ? 'rgba(239,68,68,0.07)' : 'var(--bg2)',
|
||||
border: `1px solid ${isFav ? 'rgba(239,68,68,0.25)' : 'var(--border)'}`,
|
||||
borderRadius: 10,
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.extra_cost != null && item.extra_cost !== 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>+{Number(item.extra_cost).toFixed(2)} €</div>
|
||||
)}
|
||||
{item.price != null && item.price !== 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>+{Number(item.price).toFixed(2)} €</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onToggle(item.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, flexShrink: 0, color: isFav ? '#ef4444' : 'var(--muted)' }}
|
||||
>
|
||||
<Heart filled={isFav} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFavoritesModal({ product, waiterAttrFavs, onSave, onClose }) {
|
||||
// Local state: { quick: id[], ingredients: id[], options: id[], prefs: id[] }
|
||||
// Start from waiter overrides if they exist, otherwise seed from manager's is_favorite flags
|
||||
function seedFromManager() {
|
||||
return {
|
||||
quick: (product.quick_options || []).filter(x => x.is_favorite).map(x => x.id),
|
||||
ingredients: (product.ingredients || []).filter(x => x.is_favorite).map(x => x.id),
|
||||
options: (product.options || []).filter(x => x.is_favorite).map(x => x.id),
|
||||
prefs: (product.preference_sets|| []).filter(x => x.is_favorite).map(x => x.id),
|
||||
}
|
||||
}
|
||||
|
||||
const [local, setLocal] = useState(() => waiterAttrFavs ?? seedFromManager())
|
||||
|
||||
const hasAnything = (
|
||||
(product.quick_options || []).length > 0 ||
|
||||
(product.ingredients || []).length > 0 ||
|
||||
(product.options || []).length > 0 ||
|
||||
(product.preference_sets || []).length > 0
|
||||
)
|
||||
|
||||
function toggle(section, id) {
|
||||
setLocal(prev => {
|
||||
const arr = prev[section] || []
|
||||
return {
|
||||
...prev,
|
||||
[section]: arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const totalSelected = (local.quick?.length || 0) + (local.ingredients?.length || 0) + (local.options?.length || 0) + (local.prefs?.length || 0)
|
||||
|
||||
function handleSave() {
|
||||
// If nothing selected, clear the override (fall back to manager)
|
||||
const isEmpty = totalSelected === 0
|
||||
onSave(isEmpty ? null : local)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setLocal({ quick: [], ingredients: [], options: [], prefs: [] })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 100 }}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||
zIndex: 101,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
maxHeight: '88vh',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 -8px 40px rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
{/* Handle bar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 0' }}>
|
||||
<div style={{ width: 36, height: 4, borderRadius: 2, background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '14px 16px 12px', borderBottom: '1px solid var(--border)', flexShrink: 0,
|
||||
}}>
|
||||
<Thumb product={product} size={38} radius={9} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{waiterAttrFavs ? 'Προσωπικές ρυθμίσεις ενεργές' : 'Χρήση ρυθμίσεων διαχειριστή'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4, fontSize: 20, lineHeight: 1 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 16px 0' }}>
|
||||
{!hasAnything ? (
|
||||
<p style={{ color: 'var(--muted)', fontSize: 14, textAlign: 'center', padding: '32px 0' }}>
|
||||
Αυτό το προϊόν δεν έχει επιλογές, υλικά ή προτιμήσεις.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
Επίλεξε ποιες επιλογές θα εμφανίζονται στην καρτέλα <strong style={{ color: '#ef4444' }}>♥ Αγαπημένα</strong> για αυτό το προϊόν. Αν δεν επιλέξεις τίποτα, θα χρησιμοποιηθούν οι ρυθμίσεις του διαχειριστή.
|
||||
</p>
|
||||
|
||||
<AttrSection
|
||||
title="Γρήγορες Επιλογές"
|
||||
items={product.quick_options}
|
||||
favIds={local.quick || []}
|
||||
onToggle={id => toggle('quick', id)}
|
||||
/>
|
||||
<AttrSection
|
||||
title="Υλικά"
|
||||
items={product.ingredients}
|
||||
favIds={local.ingredients || []}
|
||||
onToggle={id => toggle('ingredients', id)}
|
||||
/>
|
||||
<AttrSection
|
||||
title="Extras"
|
||||
items={product.options}
|
||||
favIds={local.options || []}
|
||||
onToggle={id => toggle('options', id)}
|
||||
/>
|
||||
<AttrSection
|
||||
title="Προτιμήσεις"
|
||||
items={product.preference_sets}
|
||||
favIds={local.prefs || []}
|
||||
onToggle={id => toggle('prefs', id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border)', flexShrink: 0,
|
||||
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
|
||||
}}>
|
||||
{waiterAttrFavs && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
padding: '0 16px', height: 44, borderRadius: 12,
|
||||
border: '1.5px solid var(--border)',
|
||||
background: 'var(--bg2)', color: 'var(--muted)',
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Επαναφορά
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1, height: 44, borderRadius: 12, border: 'none',
|
||||
background: totalSelected > 0 ? '#ef4444' : 'var(--accent)',
|
||||
color: '#fff',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{totalSelected > 0 ? `Αποθήκευση (${totalSelected} επιλογές)` : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Product row (ALL tab) ─────────────────────────────────────────────────────
|
||||
function ProductRow({ product, isFav, hasAttrOverride, onToggleFav, onEditAttrs }) {
|
||||
const hasAttrs = (
|
||||
(product.quick_options || []).length > 0 ||
|
||||
(product.ingredients || []).length > 0 ||
|
||||
(product.options || []).length > 0 ||
|
||||
(product.preference_sets || []).length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 16px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Thumb product={product} />
|
||||
|
||||
{/* Tappable area — opens attr editor */}
|
||||
<div
|
||||
onClick={hasAttrs ? () => onEditAttrs(product) : undefined}
|
||||
style={{ flex: 1, minWidth: 0, cursor: hasAttrs ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product.name}
|
||||
</div>
|
||||
{hasAttrOverride && (
|
||||
<Heart filled size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: hasAttrs ? 'var(--accent)' : 'var(--muted)' }}>
|
||||
{hasAttrs ? 'Επιλογές αγαπημένων →' : `${Number(product.base_price).toFixed(2)} €`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heart — toggles product in/out of favorites list */}
|
||||
<button
|
||||
onClick={() => onToggleFav(product.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 6, color: isFav ? '#ef4444' : 'var(--muted)', flexShrink: 0 }}
|
||||
>
|
||||
<Heart filled={isFav} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Draggable favorites list (pointer-events — works on touch + mouse) ─────────
|
||||
function FavoritesList({ items, products, onReorder }) {
|
||||
const [dragging, setDragging] = useState(null)
|
||||
const [dropIndex, setDropIndex] = useState(null)
|
||||
const containerRef = useRef(null)
|
||||
const stateRef = useRef({ dragging: null, dropIndex: null })
|
||||
|
||||
function getProductById(id) { return products.find(p => p.id === id) }
|
||||
|
||||
function computeDropIndex(clientY) {
|
||||
if (!containerRef.current) return null
|
||||
const rows = [...containerRef.current.querySelectorAll('[data-drag-row]')]
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect()
|
||||
if (clientY < rect.top + rect.height * 0.51) return i
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
const onPointerDown = useCallback((e, idx) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
stateRef.current = { dragging: idx, dropIndex: null }
|
||||
setDragging(idx)
|
||||
|
||||
function onMove(ev) {
|
||||
const di = computeDropIndex(ev.clientY)
|
||||
if (di !== stateRef.current.dropIndex) {
|
||||
stateRef.current.dropIndex = di
|
||||
setDropIndex(di)
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
const { dragging: from, dropIndex: to } = stateRef.current
|
||||
if (from != null && to != null) {
|
||||
const next = [...items]
|
||||
const [moved] = next.splice(from, 1)
|
||||
const insertAt = to > from ? to - 1 : to
|
||||
next.splice(insertAt, 0, moved)
|
||||
onReorder(next)
|
||||
}
|
||||
stateRef.current = { dragging: null, dropIndex: null }
|
||||
setDragging(null)
|
||||
setDropIndex(null)
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}, [items, onReorder])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, padding: 32, gap: 12 }}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
<p style={{ fontSize: 14, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
Δεν έχεις προσθέσει αγαπημένα ακόμα.<br />Πήγαινε στην καρτέλα ΌΛΑ και πάτα την καρδιά δίπλα στα προϊόντα.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ flex: 1, overflowY: 'auto', touchAction: 'pan-x' }}>
|
||||
{dropIndex === 0 && dragging != null && (
|
||||
<div style={{ height: 3, background: '#22c55e', margin: '0 16px', borderRadius: 2 }} />
|
||||
)}
|
||||
|
||||
{items.map((productId, idx) => {
|
||||
const product = getProductById(productId)
|
||||
if (!product) return null
|
||||
const isDragging = dragging === idx
|
||||
return (
|
||||
<div key={productId}>
|
||||
<div
|
||||
data-drag-row
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '11px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: isDragging ? 'var(--bg3)' : 'var(--bg)',
|
||||
opacity: isDragging ? 0.45 : 1,
|
||||
transition: 'opacity 120ms',
|
||||
userSelect: 'none', WebkitUserSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onPointerDown={e => onPointerDown(e, idx)}
|
||||
style={{
|
||||
color: 'var(--muted)', flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column', gap: 2.5, padding: '6px 4px',
|
||||
cursor: 'grab', touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{[0,1,2].map(i => <span key={i} style={{ display: 'block', width: 16, height: 2.5, background: 'currentColor', borderRadius: 1 }} />)}
|
||||
</div>
|
||||
|
||||
<Thumb product={product} size={38} radius={9} />
|
||||
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product.name}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', flexShrink: 0 }}>{Number(product.base_price).toFixed(2)} €</span>
|
||||
</div>
|
||||
|
||||
{dropIndex === idx + 1 && dragging != null && dragging !== idx && (
|
||||
<div style={{ height: 3, background: '#22c55e', margin: '0 16px', borderRadius: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
export default function FavoritesSetupPage() {
|
||||
const navigate = useNavigate()
|
||||
const { products, categories } = useProductCache()
|
||||
const { favorites, toggleFavorite, reorderFavorites, itemFavorites, setItemFavorites } = useWaiterFavoritesStore()
|
||||
const [tab, setTab] = useState('all')
|
||||
const [editingProduct, setEditingProduct] = useState(null) // product object for ItemFavoritesModal
|
||||
|
||||
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
return (
|
||||
<div className="page" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(-1)}>←</button>
|
||||
<span className="top-bar__title">Ρύθμιση Αγαπημένων</span>
|
||||
<div style={{ width: 40 }} />
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', flexShrink: 0, background: 'var(--bg)' }}>
|
||||
{[
|
||||
{ key: 'all', label: 'ΌΛΑ' },
|
||||
{ key: 'favorites', label: `ΑΓΑΠΗΜΈΝΑ (${favorites.length})` },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
flex: 1, padding: '12px 8px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
|
||||
color: tab === t.key ? 'var(--accent)' : 'var(--muted)',
|
||||
borderBottom: `2.5px solid ${tab === t.key ? 'var(--accent)' : 'transparent'}`,
|
||||
transition: 'color 120ms, border-color 120ms',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{tab === 'all' ? (
|
||||
topLevel.map(cat => {
|
||||
const subcats = categories.filter(c => c.parent_id === cat.id)
|
||||
const directProds = products.filter(p => p.category_id === cat.id && p.is_available)
|
||||
const subProds = subcats.flatMap(sc => products.filter(p => p.category_id === sc.id && p.is_available))
|
||||
const allProds = [...directProds, ...subProds]
|
||||
if (allProds.length === 0) return null
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div style={{
|
||||
padding: '8px 16px', fontSize: 11, fontWeight: 700,
|
||||
color: cat.color || 'var(--muted)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
background: 'var(--bg2)', borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
}}>
|
||||
{cat.name}
|
||||
</div>
|
||||
{allProds.map(p => (
|
||||
<ProductRow
|
||||
key={p.id}
|
||||
product={p}
|
||||
isFav={favorites.includes(p.id)}
|
||||
hasAttrOverride={!!itemFavorites[p.id]}
|
||||
onToggleFav={toggleFavorite}
|
||||
onEditAttrs={setEditingProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<FavoritesList
|
||||
items={favorites}
|
||||
products={products}
|
||||
onReorder={reorderFavorites}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Item attribute favorites modal */}
|
||||
{editingProduct && (
|
||||
<ItemFavoritesModal
|
||||
product={editingProduct}
|
||||
waiterAttrFavs={itemFavorites[editingProduct.id] ?? null}
|
||||
onSave={attrFavs => setItemFavorites(editingProduct.id, attrFavs)}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export default function LoginPage() {
|
||||
// We send waiter id as identifier; backend matches by id+pin
|
||||
const { data } = await client.post('/api/auth/login-by-id', { waiter_id: selectedWaiter.id, pin })
|
||||
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
|
||||
navigate('/tables')
|
||||
navigate('/tables', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Λανθασμένο PIN')
|
||||
} finally {
|
||||
|
||||
789
waiter_pwa/src/pages/OrderLogPage.jsx
Normal file
789
waiter_pwa/src/pages/OrderLogPage.jsx
Normal file
@@ -0,0 +1,789 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import AppShell from '../components/AppShell'
|
||||
import client from '../api/client'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtDateTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('el-GR', {
|
||||
day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function fmtPrice(n) {
|
||||
return '€' + parseFloat(n || 0).toFixed(2)
|
||||
}
|
||||
|
||||
// ── icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PrintOkIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 9V2h12v7" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" stroke="#22c55e" strokeWidth="2"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PrintFailIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 9V2h12v7" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" stroke="#ef4444" strokeWidth="2"/>
|
||||
<path d="M10 17l4 4M14 17l-4 4" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Receipt SVG from ASSETS/ICONS/receipt.svg, coloured by fiscal status
|
||||
function FiscalStatusIcon({ status }) {
|
||||
if (!status) return null
|
||||
const color = status === 'success' ? '#22c55e' : status === 'pending' ? '#f59e0b' : '#ef4444'
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 13H21V19C21 20.1046 20.1046 21 19 21M17 13V19C17 20.1046 17.8954 21 19 21M17 13V5.75707C17 4.85168 17 4.39898 16.8098 4.13646C16.6439 3.90746 16.3888 3.75941 16.1076 3.72897C15.7853 3.69408 15.3923 3.91868 14.6062 4.36788L14.2938 4.54637C14.0045 4.7117 13.8598 4.79438 13.7062 4.82675C13.5702 4.85539 13.4298 4.85539 13.2938 4.82675C13.1402 4.79438 12.9955 4.7117 12.7062 4.54637L10.7938 3.45359C10.5045 3.28826 10.3598 3.20559 10.2062 3.17322C10.0702 3.14457 9.92978 3.14457 9.79383 3.17322C9.64019 3.20559 9.49552 3.28826 9.20618 3.4536L7.29382 4.54637C7.00448 4.71171 6.85981 4.79438 6.70617 4.82675C6.57022 4.85539 6.42978 4.85539 6.29383 4.82675C6.14019 4.79438 5.99552 4.71171 5.70618 4.54637L5.39382 4.36788C4.60772 3.91868 4.21467 3.69408 3.89237 3.72897C3.61123 3.75941 3.35611 3.90746 3.1902 4.13646C3 4.39898 3 4.85168 3 5.75707V16.2C3 17.8801 3 18.7202 3.32698 19.362C3.6146 19.9264 4.07354 20.3854 4.63803 20.673C5.27976 21 6.11984 21 7.8 21H19M12 10.5C11.5 10.376 10.6851 10.3714 10 10.376C9.77091 10.3775 9.90941 10.3678 9.6 10.376C8.79258 10.4012 8.00165 10.7368 8 11.6875C7.99825 12.7003 9 13 10 13C11 13 12 13.2312 12 14.3125C12 15.1251 11.1925 15.4812 10.1861 15.5991C9.3861 15.5991 9 15.625 8 15.5M10 16V17M10 8.99998V9.99998" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactIcon({ active }) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="5" width="18" height="3" rx="1.5" fill={active ? 'var(--accent)' : 'var(--muted)'}/>
|
||||
<rect x="3" y="11" width="18" height="3" rx="1.5" fill={active ? 'var(--accent)' : 'var(--muted)'}/>
|
||||
<rect x="3" y="17" width="18" height="3" rx="1.5" fill={active ? 'var(--accent)' : 'var(--muted)'}/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailedIcon({ active }) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="18" height="7" rx="1.5" fill={active ? 'var(--accent)' : 'var(--muted)'}/>
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" fill={active ? 'var(--accent)' : 'var(--muted)'}/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 6h18M7 12h10M11 18h2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── action sheet — long-press on detailed cards ───────────────────────────────
|
||||
|
||||
function PrintActionSheet({ order, onRetry, onCancel, onClose, busy }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', background: 'var(--bg)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
padding: '12px 16px 32px',
|
||||
boxShadow: '0 -4px 20px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--border)', margin: '0 auto 16px' }} />
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>
|
||||
Πρόβλημα Εκτύπωσης
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 20 }}>
|
||||
Παραγγελία #{order.id} · {order.table_name} · {fmtTime(order.opened_at)}
|
||||
{order.print_retry_count > 0 && (
|
||||
<span style={{ marginLeft: 8, color: '#f59e0b' }}>
|
||||
{order.print_retry_count} επανάληψ{order.print_retry_count === 1 ? 'η' : 'εις'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '14px', borderRadius: 12, border: 'none',
|
||||
background: 'var(--accent)', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700, cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy ? 'Αποστολή…' : 'Εκτύπωση Τώρα'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '14px', borderRadius: 12,
|
||||
border: '1px solid #fee2e2', background: '#fff5f5',
|
||||
fontSize: 15, fontWeight: 600, color: '#dc2626',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Ακύρωση Εκτύπωσης
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '14px', borderRadius: 12,
|
||||
border: '1px solid var(--border)', background: 'var(--bg2)',
|
||||
fontSize: 15, color: 'var(--muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Κλείσιμο
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── filter sheet ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterSheet({ filter, dateFrom, dateTo, onApply, onClose }) {
|
||||
const [localFilter, setLocalFilter] = useState(filter)
|
||||
const [localFrom, setLocalFrom] = useState(dateFrom)
|
||||
const [localTo, setLocalTo] = useState(dateTo)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', background: 'var(--bg)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
padding: '12px 16px 40px',
|
||||
boxShadow: '0 -4px 20px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--border)', margin: '0 auto 20px' }} />
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>Φίλτρα</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Κατάσταση
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{[
|
||||
{ value: 'all', label: 'Όλες' },
|
||||
{ value: 'success', label: 'Επιτυχής' },
|
||||
{ value: 'failed', label: 'Αποτυχημένη' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setLocalFilter(opt.value)}
|
||||
style={{
|
||||
flex: 1, padding: '10px 4px', borderRadius: 10,
|
||||
border: `1px solid ${localFilter === opt.value ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: localFilter === opt.value ? 'rgba(var(--accent-rgb,59,130,246),0.12)' : 'var(--bg2)',
|
||||
color: localFilter === opt.value ? 'var(--accent)' : 'var(--text)',
|
||||
fontSize: 13, fontWeight: localFilter === opt.value ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Εύρος ημερομηνίας/ώρας
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', width: 28 }}>Από</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={localFrom}
|
||||
onChange={e => setLocalFrom(e.target.value)}
|
||||
style={{
|
||||
flex: 1, height: 38, borderRadius: 10,
|
||||
border: '1px solid var(--border)', background: 'var(--bg2)',
|
||||
padding: '0 10px', fontSize: 13, color: 'var(--text)',
|
||||
fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', width: 28 }}>Έως</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={localTo}
|
||||
onChange={e => setLocalTo(e.target.value)}
|
||||
style={{
|
||||
flex: 1, height: 38, borderRadius: 10,
|
||||
border: '1px solid var(--border)', background: 'var(--bg2)',
|
||||
padding: '0 10px', fontSize: 13, color: 'var(--text)',
|
||||
fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(localFrom || localTo) && (
|
||||
<button
|
||||
onClick={() => { setLocalFrom(''); setLocalTo('') }}
|
||||
style={{
|
||||
alignSelf: 'flex-start', fontSize: 12, color: 'var(--muted)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '2px 0',
|
||||
}}
|
||||
>
|
||||
Καθαρισμός ημερομηνίας
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onApply(localFilter, localFrom, localTo)}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
|
||||
background: 'var(--accent)', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Εφαρμογή
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── compact card (click = expand inline, expanded shows action buttons) ────────
|
||||
|
||||
function CompactCard({ order, expanded, onToggle, onRetry, onCancel, busy }) {
|
||||
const isPending = order.print_status === 'pending'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: `1px solid ${isPending ? 'rgba(239,68,68,0.4)' : 'var(--border)'}`,
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onClick={() => onToggle(order.id)}
|
||||
>
|
||||
{/* Always-visible summary row */}
|
||||
<div style={{ padding: '10px 14px' }}>
|
||||
{/* 3-column grid: table name | centered order# | print icon */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{order.table_name || '—'}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
#{order.id}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4 }}>
|
||||
<FiscalStatusIcon status={order.fiscal_status} />
|
||||
{isPending ? <PrintFailIcon /> : <PrintOkIcon />}
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 2: item count | total */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{order.items.length} αντ.
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>
|
||||
{fmtPrice(order.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded section — items + action buttons for pending */}
|
||||
{expanded && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px dashed var(--border)', margin: '0 14px' }} />
|
||||
<div style={{ padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--text)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.product_name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 8, flexShrink: 0 }}>
|
||||
×{item.quantity % 1 === 0 ? item.quantity : item.quantity.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Total + fiscal info */}
|
||||
<div style={{ borderTop: '1px dashed var(--border)', margin: '0 14px' }} />
|
||||
<div style={{ padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--muted)' }}>Σύνολο</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{isPending && order.print_retry_count > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#f59e0b' }}>
|
||||
{order.print_retry_count} επανάληψ{order.print_retry_count === 1 ? 'η' : 'εις'}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{fmtPrice(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{order.fiscal_status && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Φορολογική εκτύπωση</span>
|
||||
<span style={{ fontWeight: 600, color: order.fiscal_status === 'success' ? '#22c55e' : order.fiscal_status === 'pending' ? '#f59e0b' : '#ef4444' }}>
|
||||
{order.fiscal_status === 'success' ? 'Επιτυχής' : order.fiscal_status === 'pending' ? 'Εκκρεμεί…' : 'Αποτυχία'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '0 14px' }} />
|
||||
<div style={{ padding: '10px 14px', display: 'flex', gap: 8 }} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onRetry(order)}
|
||||
disabled={busy}
|
||||
style={{
|
||||
flex: 1, padding: '10px 8px', borderRadius: 10, border: 'none',
|
||||
background: 'var(--accent)', color: '#fff',
|
||||
fontSize: 13, fontWeight: 700, cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy ? 'Αποστολή…' : 'Εκτύπωση Τώρα'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCancel(order)}
|
||||
disabled={busy}
|
||||
style={{
|
||||
flex: 1, padding: '10px 8px', borderRadius: 10,
|
||||
border: '1px solid #fee2e2', background: '#fff5f5',
|
||||
fontSize: 13, fontWeight: 600, color: '#dc2626',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── detailed card (long-press = action sheet) ─────────────────────────────────
|
||||
|
||||
function DetailedCard({ order, onLongPress }) {
|
||||
const isPending = order.print_status === 'pending'
|
||||
const pressTimer = useRef(null)
|
||||
|
||||
function startPress() {
|
||||
if (!isPending) return
|
||||
pressTimer.current = setTimeout(() => onLongPress(order), 600)
|
||||
}
|
||||
|
||||
function cancelPress() {
|
||||
clearTimeout(pressTimer.current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={startPress}
|
||||
onPointerUp={cancelPress}
|
||||
onPointerLeave={cancelPress}
|
||||
style={{
|
||||
background: 'var(--bg2)',
|
||||
border: `1px solid ${isPending ? 'rgba(239,68,68,0.4)' : 'var(--border)'}`,
|
||||
borderRadius: 14,
|
||||
cursor: isPending ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 14px',
|
||||
background: isPending ? 'rgba(239,68,68,0.06)' : 'var(--bg3)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
borderRadius: '14px 14px 0 0',
|
||||
}}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', flex: 1 }}>
|
||||
{order.table_name || '—'}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)' }}>#{order.id}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<FiscalStatusIcon status={order.fiscal_status} />
|
||||
{isPending ? <PrintFailIcon /> : <PrintOkIcon />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta rows */}
|
||||
<div style={{ padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Παραγγέλθηκε</span>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{fmtDateTime(order.opened_at)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Εκτύπωση</span>
|
||||
<span style={{ fontWeight: 600, color: isPending ? '#ef4444' : '#22c55e' }}>
|
||||
{isPending
|
||||
? `Εκκρεμεί (${order.print_retry_count} επαν.)`
|
||||
: `OK ${fmtTime(order.print_attempted_at)}`}
|
||||
</span>
|
||||
</div>
|
||||
{order.fiscal_status && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Φορολογική εκτύπωση</span>
|
||||
<span style={{ fontWeight: 600, color: order.fiscal_status === 'success' ? '#22c55e' : order.fiscal_status === 'pending' ? '#f59e0b' : '#ef4444' }}>
|
||||
{order.fiscal_status === 'success' ? 'Επιτυχής' : order.fiscal_status === 'pending' ? 'Εκκρεμεί…' : 'Αποτυχία'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderTop: '1px dashed var(--border)', margin: '0 14px' }} />
|
||||
|
||||
{/* Items — no fixed height, auto-sizes to content */}
|
||||
<div style={{ padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.product_name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 8, flexShrink: 0 }}>
|
||||
×{item.quantity % 1 === 0 ? item.quantity : item.quantity.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderTop: '1px dashed var(--border)', margin: '0 14px' }} />
|
||||
|
||||
{/* Total */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 14px', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 600 }}>Σύνολο</span>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 700 }}>{fmtPrice(order.total)}</span>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<div style={{ padding: '0 14px 10px', textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>Κρατήστε πατημένο για επιλογές</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OrderLogPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialFilter = searchParams.get('filter') === 'failed' ? 'failed' : 'all'
|
||||
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('compact')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState(initialFilter)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [actionOrder, setActionOrder] = useState(null)
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/orders/shift-log')
|
||||
setOrders(res.data?.orders ?? [])
|
||||
} catch {
|
||||
// keep stale data
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(load, 10000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
function toggleExpand(id) {
|
||||
setExpandedId(prev => prev === id ? null : id)
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
let filtered = q
|
||||
? orders.filter(o =>
|
||||
(o.table_name || '').toLowerCase().includes(q) ||
|
||||
String(o.id).includes(q) ||
|
||||
o.items.some(i => i.product_name.toLowerCase().includes(q))
|
||||
)
|
||||
: [...orders]
|
||||
|
||||
if (filter === 'failed') {
|
||||
filtered = filtered.filter(o => o.print_status === 'pending')
|
||||
} else if (filter === 'success') {
|
||||
filtered = filtered.filter(o => o.print_status !== 'pending')
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
const from = new Date(dateFrom)
|
||||
filtered = filtered.filter(o => new Date(o.opened_at) >= from)
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(dateTo)
|
||||
filtered = filtered.filter(o => new Date(o.opened_at) <= to)
|
||||
}
|
||||
|
||||
async function handleRetry(order) {
|
||||
setActionBusy(true)
|
||||
try {
|
||||
await client.post(`/api/orders/${order.id}/retry-print`)
|
||||
await load()
|
||||
setActionOrder(null)
|
||||
setExpandedId(null)
|
||||
} catch {
|
||||
// stay open so user can try again
|
||||
} finally {
|
||||
setActionBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelJobs(order) {
|
||||
setActionBusy(true)
|
||||
try {
|
||||
const pendingJobs = order.print_jobs.filter(j => j.status === 'pending')
|
||||
for (const job of pendingJobs) {
|
||||
await client.post(`/api/orders/${order.id}/print-jobs/${job.id}/cancel`)
|
||||
}
|
||||
await load()
|
||||
setActionOrder(null)
|
||||
setExpandedId(null)
|
||||
} catch {
|
||||
// stay open
|
||||
} finally {
|
||||
setActionBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = orders.filter(o => o.print_status === 'pending').length
|
||||
const hasActiveFilters = filter !== 'all' || !!dateFrom || !!dateTo
|
||||
|
||||
return (
|
||||
<AppShell title="Αρχ. Παραγγ.">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, alignItems: 'center',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Τραπέζι, #παραγγελία, αντικείμενο…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{
|
||||
flex: 1, height: 38, borderRadius: 10,
|
||||
border: '1px solid var(--border)', background: 'var(--bg2)',
|
||||
padding: '0 12px', fontSize: 14, color: 'var(--text)',
|
||||
fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setView(v => v === 'compact' ? 'detailed' : 'compact')}
|
||||
style={{
|
||||
width: 38, height: 38, borderRadius: 10,
|
||||
border: `1px solid ${view === 'detailed' ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: view === 'detailed' ? 'var(--accent)18' : 'var(--bg2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={view === 'compact' ? 'Λεπτομερής προβολή' : 'Συμπαγής προβολή'}
|
||||
>
|
||||
{view === 'compact' ? <DetailedIcon active={false} /> : <CompactIcon active={false} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending banner */}
|
||||
{pendingCount > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
borderBottom: '1px solid rgba(239,68,68,0.2)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="#ef4444" strokeWidth="2"/>
|
||||
<path d="M12 8v4M12 16h.01" stroke="#ef4444" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 13, color: '#ef4444', fontWeight: 600 }}>
|
||||
{pendingCount} παραγγελί{pendingCount !== 1 ? 'ες' : 'α'} με πρόβλημα εκτύπωσης
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter chips */}
|
||||
{hasActiveFilters && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 6, alignItems: 'center',
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0, flexWrap: 'wrap',
|
||||
}}>
|
||||
{filter !== 'all' && (
|
||||
<span style={{
|
||||
fontSize: 12, padding: '3px 10px', borderRadius: 999,
|
||||
background: filter === 'failed' ? 'rgba(239,68,68,0.12)' : 'rgba(34,197,94,0.12)',
|
||||
color: filter === 'failed' ? '#ef4444' : '#22c55e',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{filter === 'failed' ? 'Αποτυχημένη εκτύπωση' : 'Επιτυχής εκτύπωση'}
|
||||
</span>
|
||||
)}
|
||||
{dateFrom && (
|
||||
<span style={{ fontSize: 12, padding: '3px 10px', borderRadius: 999, background: 'var(--bg3)', color: 'var(--muted)' }}>
|
||||
Από {new Date(dateFrom).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
{dateTo && (
|
||||
<span style={{ fontSize: 12, padding: '3px 10px', borderRadius: 999, background: 'var(--bg3)', color: 'var(--muted)' }}>
|
||||
Έως {new Date(dateTo).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setFilter('all'); setDateFrom(''); setDateTo('') }}
|
||||
style={{ fontSize: 12, color: 'var(--muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 6px' }}
|
||||
>
|
||||
✕ Καθαρισμός
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 14px 80px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{loading && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '40px 0', fontSize: 14 }}>Φόρτωση…</p>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '40px 0', fontSize: 14 }}>
|
||||
{search || hasActiveFilters ? 'Δεν βρέθηκαν παραγγελίες' : 'Δεν υπάρχουν παραγγελίες σε αυτή τη βάρδια'}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map(order =>
|
||||
view === 'compact'
|
||||
? (
|
||||
<CompactCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
expanded={expandedId === order.id}
|
||||
onToggle={toggleExpand}
|
||||
onRetry={handleRetry}
|
||||
onCancel={handleCancelJobs}
|
||||
busy={actionBusy}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DetailedCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onLongPress={setActionOrder}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating filter button */}
|
||||
<button
|
||||
onClick={() => setShowFilter(true)}
|
||||
style={{
|
||||
position: 'absolute', bottom: 20, right: 20,
|
||||
width: 52, height: 52, borderRadius: '50%',
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: hasActiveFilters ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: hasActiveFilters ? '#fff' : 'var(--text)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.25)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
}}
|
||||
title="Φίλτρα"
|
||||
>
|
||||
<FilterIcon />
|
||||
{hasActiveFilters && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
border: '2px solid var(--bg)',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action sheet — long-press on detailed cards */}
|
||||
{actionOrder && (
|
||||
<PrintActionSheet
|
||||
order={actionOrder}
|
||||
busy={actionBusy}
|
||||
onRetry={() => handleRetry(actionOrder)}
|
||||
onCancel={() => handleCancelJobs(actionOrder)}
|
||||
onClose={() => setActionOrder(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filter sheet */}
|
||||
{showFilter && (
|
||||
<FilterSheet
|
||||
filter={filter}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onApply={(f, from, to) => {
|
||||
setFilter(f)
|
||||
setDateFrom(from)
|
||||
setDateTo(to)
|
||||
setShowFilter(false)
|
||||
}}
|
||||
onClose={() => setShowFilter(false)}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
271
waiter_pwa/src/pages/ShiftOverviewPage.jsx
Normal file
271
waiter_pwa/src/pages/ShiftOverviewPage.jsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import AppShell from '../components/AppShell'
|
||||
import client from '../api/client'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtDuration(seconds) {
|
||||
if (seconds == null || seconds < 0) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h === 0) return `${m}λ`
|
||||
if (m === 0) return `${h}ω`
|
||||
return `${h}ω ${m}λ`
|
||||
}
|
||||
|
||||
function fmtEuro(n) {
|
||||
return '€' + parseFloat(n || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function sinceSeconds(isoStart) {
|
||||
if (!isoStart) return 0
|
||||
return Math.floor((Date.now() - new Date(isoStart).getTime()) / 1000)
|
||||
}
|
||||
|
||||
function breakDurationSeconds(b) {
|
||||
if (!b.started_at) return 0
|
||||
const end = b.ended_at ? new Date(b.ended_at) : new Date()
|
||||
return Math.floor((end - new Date(b.started_at)) / 1000)
|
||||
}
|
||||
|
||||
// ── stat card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, sub, accent }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 16, padding: '16px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div>
|
||||
<div style={{
|
||||
fontSize: 28, fontWeight: 700, lineHeight: 1.1,
|
||||
color: accent || 'var(--text)',
|
||||
fontFamily: "'ui-monospace','SFMono-Regular',monospace",
|
||||
}}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--muted)' }}>{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── horizontal bar chart (per-hour) ──────────────────────────────────────────
|
||||
|
||||
function PerHourChart({ title, data, accent = 'var(--accent)' }) {
|
||||
const hours = Object.keys(data).map(Number).sort((a, b) => a - b)
|
||||
if (hours.length === 0) return null
|
||||
const maxValue = Math.max(...hours.map(h => data[h]), 1)
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{hours.map(h => {
|
||||
const pct = (data[h] / maxValue) * 100
|
||||
return (
|
||||
<div key={h} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 32, fontSize: 11, fontWeight: 700, color: 'var(--muted)', textAlign: 'right', flexShrink: 0 }}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 20, background: 'var(--bg3)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 4,
|
||||
width: `${Math.max(pct, data[h] > 0 ? 2 : 0)}%`,
|
||||
background: accent,
|
||||
transition: 'width 300ms ease',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
{pct > 18 && (
|
||||
<span style={{ fontSize: 10, fontWeight: 800, color: 'var(--accent-fg)', paddingLeft: 6 }}>
|
||||
{data[h]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 20, fontSize: 11, fontWeight: 700, color: 'var(--text)', flexShrink: 0 }}>
|
||||
{pct <= 18 ? data[h] : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── breaks timeline ───────────────────────────────────────────────────────────
|
||||
|
||||
function BreakRow({ brk, index }) {
|
||||
const dur = breakDurationSeconds(brk)
|
||||
const active = !brk.ended_at
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', borderRadius: 10,
|
||||
background: active ? 'rgba(251,146,60,0.1)' : 'var(--bg2)',
|
||||
border: `1px solid ${active ? '#f97316' : 'var(--border)'}`,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
|
||||
background: active ? '#f97316' : 'var(--bg3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, fontWeight: 700, color: active ? 'white' : 'var(--muted)',
|
||||
}}>{index + 1}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>
|
||||
{fmtTime(brk.started_at)} – {active ? 'Τώρα' : fmtTime(brk.ended_at)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 1 }}>
|
||||
{fmtDuration(dur)}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '3px 8px', borderRadius: 999,
|
||||
background: '#f97316', color: 'white',
|
||||
}}>ΕΝΕΡΓΟ</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ShiftOverviewPage() {
|
||||
const { data: shift, isLoading: shiftLoading } = useQuery({
|
||||
queryKey: ['my-shift'],
|
||||
queryFn: () => client.get('/api/shifts/my').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const hideRevenue = settings?.['shifts.hide_revenue_from_waiters']?.value === 'true'
|
||||
|
||||
// /api/orders/my returns only open orders belonging to this waiter —
|
||||
// enough for per-hour chart of current open tables
|
||||
const { data: openOrders = [] } = useQuery({
|
||||
queryKey: ['my-orders-shift'],
|
||||
queryFn: () => client.get('/api/orders/my').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
enabled: !!shift,
|
||||
})
|
||||
|
||||
if (shiftLoading) {
|
||||
return (
|
||||
<AppShell title="Shift Overview">
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', fontSize: 14 }}>
|
||||
Φόρτωση…
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!shift) {
|
||||
return (
|
||||
<AppShell title="Shift Overview">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 40 }}>⏸</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Δεν υπάρχει ενεργή βάρδια</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)' }}>Ξεκινήστε βάρδια για να δείτε στατιστικά.</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Compute stats ────────────────────────────────────────────────────────────
|
||||
|
||||
const totalShiftSec = sinceSeconds(shift.started_at)
|
||||
const totalBreakSec = (shift.breaks || []).reduce((s, b) => s + breakDurationSeconds(b), 0)
|
||||
const workedSec = Math.max(0, totalShiftSec - totalBreakSec)
|
||||
|
||||
// Per-hour buckets from currently-open orders
|
||||
const ordersByHour = {}
|
||||
const itemsByHour = {}
|
||||
openOrders.forEach(o => {
|
||||
const h = new Date(o.opened_at).getHours()
|
||||
ordersByHour[h] = (ordersByHour[h] || 0) + 1
|
||||
itemsByHour[h] = (itemsByHour[h] || 0) +
|
||||
(o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.quantity, 0)
|
||||
})
|
||||
|
||||
const totalItems = openOrders.reduce((s, o) =>
|
||||
s + (o.items || []).filter(i => i.status !== 'cancelled').reduce((is, i) => is + i.quantity, 0), 0)
|
||||
|
||||
const activeBreak = (shift.breaks || []).find(b => !b.ended_at)
|
||||
|
||||
return (
|
||||
<AppShell title="Shift Overview">
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 16px 24px' }}>
|
||||
|
||||
{/* Active break banner */}
|
||||
{activeBreak && (
|
||||
<div style={{
|
||||
margin: '0 0 16px',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(251,146,60,0.12)',
|
||||
border: '1px solid #f97316',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 20 }}>☕</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#f97316' }}>Σε διάλειμμα</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>
|
||||
από {fmtTime(activeBreak.started_at)} · {fmtDuration(breakDurationSeconds(activeBreak))} μέχρι τώρα
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero stats grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 20 }}>
|
||||
<StatCard label="Έναρξη" value={fmtTime(shift.started_at)} />
|
||||
<StatCard label="Ώρες Εργασίας" value={fmtDuration(workedSec)} sub={`${fmtDuration(totalBreakSec)} διαλείμματα`} />
|
||||
<StatCard label="Αρχ. Μετρητά" value={fmtEuro(shift.starting_cash)} />
|
||||
{!hideRevenue && (
|
||||
<StatCard label="Είσπραξη" value={fmtEuro(shift.total_collected)} accent="var(--success, #22c55e)" />
|
||||
)}
|
||||
{!hideRevenue && (
|
||||
<StatCard label="Παραδ. Ποσό" value={fmtEuro(shift.net_to_deliver)} sub="Αρχ. + Είσπραξη" accent="var(--accent)" />
|
||||
)}
|
||||
<StatCard label="Ανοιχτά Τραπέζια" value={String(openOrders.length)} sub="τρέχουσες παραγγελίες" />
|
||||
<StatCard label="Είδη (ανοιχτά)" value={String(totalItems)} />
|
||||
<StatCard label="Διαλείμματα" value={String((shift.breaks || []).length)} sub={`${fmtDuration(totalBreakSec)} σύνολο`} />
|
||||
</div>
|
||||
|
||||
{/* Per-hour charts */}
|
||||
{openOrders.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 16, padding: '16px 18px', marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>Ανά Ώρα</div>
|
||||
<PerHourChart title="Παραγγελίες" data={ordersByHour} />
|
||||
<PerHourChart title="Είδη" data={itemsByHour} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breaks */}
|
||||
{(shift.breaks || []).length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>
|
||||
Διαλείμματα ({shift.breaks.length})
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{shift.breaks.map((b, i) => <BreakRow key={b.id} brk={b} index={i} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
427
waiter_pwa/src/services/offlineOrders.js
Normal file
427
waiter_pwa/src/services/offlineOrders.js
Normal file
@@ -0,0 +1,427 @@
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
function offlineItemId() {
|
||||
return `offline_item_${generateUUID()}`
|
||||
}
|
||||
|
||||
// ─── Queue an offline order creation ─────────────────────────────────────────
|
||||
// Writes a 'create_order' op into the unified offline_ops queue (sequenced).
|
||||
// Also writes a synthetic local order to pos_orders so the table card shows it.
|
||||
|
||||
export async function queueOfflineOrder({ tableId, items, waiterId }) {
|
||||
const uuid = generateUUID()
|
||||
const localOrderId = `offline_${uuid}`
|
||||
const offlineAt = new Date().toISOString()
|
||||
const seq = Date.now()
|
||||
|
||||
// Write sequenced op
|
||||
await db.offline_ops.add({
|
||||
type: 'create_order',
|
||||
uuid,
|
||||
localOrderId,
|
||||
tableId,
|
||||
items,
|
||||
waiterId,
|
||||
offlineAt,
|
||||
seq,
|
||||
synced: 0,
|
||||
serverId: null,
|
||||
})
|
||||
|
||||
// Also write to legacy offline_orders so pendingOrderCount() still works
|
||||
const localId = await db.offline_orders.add({
|
||||
uuid,
|
||||
tableId,
|
||||
items,
|
||||
waiterId,
|
||||
offlineAt,
|
||||
synced: 0,
|
||||
serverId: null,
|
||||
})
|
||||
|
||||
// Synthetic local order for immediate UI display
|
||||
const syntheticOrder = {
|
||||
id: localOrderId,
|
||||
table_id: tableId,
|
||||
status: 'open',
|
||||
opened_at: offlineAt,
|
||||
waiter_ids: waiterId ? [waiterId] : [],
|
||||
items: items.map((item) => ({
|
||||
id: offlineItemId(),
|
||||
product_id: item.product_id,
|
||||
product: item._productSnapshot || null,
|
||||
quantity: item.quantity,
|
||||
unit_price: item._unitPrice || 0,
|
||||
unit_type: item._unitType || null,
|
||||
status: 'active',
|
||||
kds_status: 'pending',
|
||||
selected_options: item.selected_options || [],
|
||||
removed_ingredients: item.removed_ingredients || [],
|
||||
notes: item.notes || '',
|
||||
})),
|
||||
payments: [],
|
||||
_isOffline: true,
|
||||
_localId: localId,
|
||||
}
|
||||
await db.pos_orders.put(syntheticOrder)
|
||||
|
||||
return { uuid, localId, syntheticOrderId: localOrderId }
|
||||
}
|
||||
|
||||
// ─── Queue an offline split ───────────────────────────────────────────────────
|
||||
|
||||
export async function queueOfflineSplit({ orderId, itemId, quantity }) {
|
||||
const order = await db.pos_orders.get(orderId)
|
||||
if (!order) throw new Error('order not found in IDB')
|
||||
|
||||
const origItem = order.items.find(i => String(i.id) === String(itemId))
|
||||
if (!origItem) throw new Error('item not found in order')
|
||||
|
||||
const splitQty = quantity
|
||||
const remainQty = origItem.quantity - splitQty
|
||||
if (remainQty <= 0) throw new Error('split quantity must be less than item quantity')
|
||||
|
||||
const newItemId = offlineItemId()
|
||||
const seq = Date.now()
|
||||
|
||||
const updatedItems = order.items.map(i =>
|
||||
String(i.id) === String(itemId) ? { ...i, quantity: remainQty } : i
|
||||
)
|
||||
const splitItem = { ...origItem, id: newItemId, quantity: splitQty }
|
||||
updatedItems.push(splitItem)
|
||||
|
||||
await db.pos_orders.put({ ...order, items: updatedItems })
|
||||
|
||||
await db.offline_ops.add({
|
||||
type: 'split',
|
||||
uuid: generateUUID(),
|
||||
localOrderId: String(orderId),
|
||||
orderId: String(orderId),
|
||||
itemId: String(itemId),
|
||||
quantity: splitQty,
|
||||
localNewItemId: newItemId,
|
||||
seq,
|
||||
synced: 0,
|
||||
})
|
||||
|
||||
return { reducedItem: { ...origItem, quantity: remainQty }, newItem: splitItem }
|
||||
}
|
||||
|
||||
// ─── Queue an offline payment ─────────────────────────────────────────────────
|
||||
// Works for BOTH real server orders and offline-created orders.
|
||||
// Everything goes into offline_ops so it's sequenced correctly.
|
||||
|
||||
export async function queueOfflinePayment({ orderId, itemIds, paymentMethod }) {
|
||||
const uuid = generateUUID()
|
||||
const offlineAt = new Date().toISOString()
|
||||
const seq = Date.now()
|
||||
|
||||
const allLocal = itemIds.every(id => String(id).startsWith('offline_item_'))
|
||||
const allReal = itemIds.every(id => typeof id === 'number' || !String(id).startsWith('offline_item_'))
|
||||
|
||||
if (allReal) {
|
||||
// Pure real IDs — can also go to legacy offline_payments for compat,
|
||||
// but put in offline_ops first so ordering is respected
|
||||
await db.offline_ops.add({
|
||||
type: 'pay_real',
|
||||
uuid,
|
||||
localOrderId: String(orderId),
|
||||
orderId: String(orderId),
|
||||
itemIds,
|
||||
paymentMethod,
|
||||
offlineAt,
|
||||
seq,
|
||||
synced: 0,
|
||||
})
|
||||
} else {
|
||||
// Contains offline item IDs (from an offline-created order or offline split)
|
||||
await db.offline_ops.add({
|
||||
type: 'pay',
|
||||
uuid,
|
||||
localOrderId: String(orderId),
|
||||
orderId: String(orderId),
|
||||
localItemIds: itemIds.filter(id => String(id).startsWith('offline_item_')),
|
||||
realItemIds: itemIds.filter(id => !String(id).startsWith('offline_item_')),
|
||||
paymentMethod,
|
||||
offlineAt,
|
||||
seq,
|
||||
synced: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
const order = await db.pos_orders.get(orderId)
|
||||
if (order) {
|
||||
const updatedItems = order.items.map(i =>
|
||||
itemIds.some(id => String(id) === String(i.id)) ? { ...i, status: 'paid' } : i
|
||||
)
|
||||
const allPaid = updatedItems.filter(i => i.status === 'active').length === 0
|
||||
await db.pos_orders.put({
|
||||
...order,
|
||||
items: updatedItems,
|
||||
status: allPaid ? 'paid' : 'partially_paid',
|
||||
})
|
||||
}
|
||||
|
||||
return uuid
|
||||
}
|
||||
|
||||
// ─── Unified flush — processes ALL offline ops in strict seq order ─────────────
|
||||
//
|
||||
// idMap tracks localId → real server id for both orders and items.
|
||||
// Each op resolves its dependencies through idMap before calling the server.
|
||||
|
||||
export async function flushAllOfflineOps() {
|
||||
// Drain legacy offline_orders first (pre-v0.6.4 records, no seq)
|
||||
try {
|
||||
await _flushLegacyOfflineOrders()
|
||||
} catch (e) {
|
||||
console.warn('[offline] legacy order drain failed:', e?.message)
|
||||
}
|
||||
|
||||
// Load all unsynced ops sorted by seq
|
||||
const pending = (await db.offline_ops.toArray())
|
||||
.filter(op => !op.synced)
|
||||
.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||
|
||||
// localId → real server id (orders and items)
|
||||
const idMap = {}
|
||||
|
||||
for (const op of pending) {
|
||||
try {
|
||||
if (op.type === 'create_order') {
|
||||
await _flushCreateOrder(op, idMap)
|
||||
} else if (op.type === 'split') {
|
||||
await _flushSplit(op, idMap)
|
||||
} else if (op.type === 'pay' || op.type === 'pay_real') {
|
||||
await _flushPay(op, idMap)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[offline] op failed, will retry:', op.type, op.uuid, e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Drain legacy offline_payments (real-ID payments queued before v0.6.4)
|
||||
try {
|
||||
await _flushLegacyOfflinePayments()
|
||||
} catch (e) {
|
||||
console.warn('[offline] legacy payment drain failed:', e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy drains (backward compat) ──────────────────────────────────────────
|
||||
|
||||
async function _flushLegacyOfflineOrders() {
|
||||
const pending = (await db.offline_orders.toArray()).filter(o => !o.synced)
|
||||
if (pending.length === 0) return
|
||||
|
||||
// Load all offline_ops uuids for dedup check (JS filter — uuid index may not exist on old DBs)
|
||||
const allOps = await db.offline_ops.toArray()
|
||||
const opUuids = new Set(allOps.map(op => op.uuid))
|
||||
|
||||
for (const entry of pending) {
|
||||
// Skip if a create_order op with same uuid is in offline_ops (handled there)
|
||||
if (opUuids.has(entry.uuid)) {
|
||||
// Mark legacy record synced — the op queue handles it
|
||||
await db.offline_orders.update(entry.localId, { synced: 1 })
|
||||
continue
|
||||
}
|
||||
// True legacy record — handle directly
|
||||
try {
|
||||
let orderId = null
|
||||
try {
|
||||
const statusRes = await client.get(`/api/tables/${entry.tableId}/status`)
|
||||
orderId = statusRes.data.active_order_id ?? null
|
||||
} catch { /* offline */ }
|
||||
|
||||
if (!orderId) {
|
||||
const createRes = await client.post('/api/orders/', { table_id: entry.tableId })
|
||||
orderId = createRes.data?.id
|
||||
if (!orderId) throw new Error('no order id returned')
|
||||
}
|
||||
|
||||
await client.post(`/api/orders/${orderId}/items`, {
|
||||
items: entry.items.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
selected_options: item.selected_options || [],
|
||||
removed_ingredients: item.removed_ingredients || [],
|
||||
notes: item.notes || '',
|
||||
})),
|
||||
})
|
||||
|
||||
const fullRes = await client.get(`/api/orders/${orderId}`)
|
||||
await db.offline_orders.update(entry.localId, { synced: 1, serverId: orderId })
|
||||
await db.pos_orders.delete(`offline_${entry.uuid}`)
|
||||
await db.pos_orders.put({
|
||||
...fullRes.data,
|
||||
waiter_ids: fullRes.data.waiters?.map(w => w.waiter_id) ?? [],
|
||||
})
|
||||
} catch { /* leave pending */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function _flushLegacyOfflinePayments() {
|
||||
const all = await db.offline_payments.toArray()
|
||||
const pending = all.filter(p => !p.synced)
|
||||
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,
|
||||
})
|
||||
await db.offline_payments.update(payment.localId, {
|
||||
synced: 1,
|
||||
isDuplicate: res.data.is_duplicate ? 1 : 0,
|
||||
})
|
||||
} catch { /* leave pending */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Op handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function _flushCreateOrder(op, idMap) {
|
||||
// Already synced by legacy drain? Check idMap or look for real order
|
||||
let orderId = null
|
||||
try {
|
||||
const statusRes = await client.get(`/api/tables/${op.tableId}/status`)
|
||||
orderId = statusRes.data.active_order_id ?? null
|
||||
} catch { /* offline */ }
|
||||
|
||||
if (!orderId) {
|
||||
const createRes = await client.post('/api/orders/', { table_id: op.tableId })
|
||||
orderId = createRes.data?.id
|
||||
if (!orderId) throw new Error('no order id returned')
|
||||
}
|
||||
|
||||
await client.post(`/api/orders/${orderId}/items`, {
|
||||
items: op.items.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
selected_options: item.selected_options || [],
|
||||
removed_ingredients: item.removed_ingredients || [],
|
||||
notes: item.notes || '',
|
||||
})),
|
||||
})
|
||||
|
||||
// Capture synthetic order item ids BEFORE deleting it
|
||||
const syntheticOrder = await db.pos_orders.get(op.localOrderId)
|
||||
const syntheticItemIds = syntheticOrder?.items?.map(i => i.id) ?? []
|
||||
|
||||
// Fetch full hydrated order
|
||||
const fullRes = await client.get(`/api/orders/${orderId}`)
|
||||
const serverOrder = fullRes.data
|
||||
|
||||
// Map localOrderId → real server order id
|
||||
idMap[op.localOrderId] = orderId
|
||||
|
||||
// Map offline item ids → real item ids by position (last N added server items)
|
||||
if (syntheticItemIds.length > 0 && serverOrder.items) {
|
||||
const addedServerItems = serverOrder.items.slice(-syntheticItemIds.length)
|
||||
syntheticItemIds.forEach((localItemId, idx) => {
|
||||
if (addedServerItems[idx]) {
|
||||
idMap[localItemId] = addedServerItems[idx].id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mark legacy offline_orders record synced too
|
||||
await db.offline_orders.where('uuid').equals(op.uuid).modify({ synced: 1, serverId: orderId })
|
||||
|
||||
await db.offline_ops.update(op.localId, { synced: 1, serverId: orderId })
|
||||
|
||||
// Replace synthetic order in IDB with real server order
|
||||
await db.pos_orders.delete(op.localOrderId)
|
||||
await db.pos_orders.put({
|
||||
...serverOrder,
|
||||
waiter_ids: serverOrder.waiters?.map(w => w.waiter_id) ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
async function _flushSplit(op, idMap) {
|
||||
const realOrderId = idMap[op.localOrderId] ?? op.orderId
|
||||
if (String(realOrderId).startsWith('offline_')) {
|
||||
// Parent order not yet synced — skip, will retry
|
||||
throw new Error('parent order not yet synced')
|
||||
}
|
||||
|
||||
const realItemId = idMap[op.itemId] ?? op.itemId
|
||||
if (String(realItemId).startsWith('offline_item_')) {
|
||||
throw new Error('source item not yet resolved')
|
||||
}
|
||||
|
||||
const res = await client.post(
|
||||
`/api/orders/${realOrderId}/items/${realItemId}/split`,
|
||||
{ quantity: op.quantity }
|
||||
)
|
||||
const newItem = res.data.find(i => i.id !== Number(realItemId))
|
||||
if (newItem) idMap[op.localNewItemId] = newItem.id
|
||||
|
||||
await db.offline_ops.update(op.localId, { synced: 1 })
|
||||
}
|
||||
|
||||
async function _flushPay(op, idMap) {
|
||||
// Resolve order id
|
||||
const realOrderId = idMap[op.localOrderId] ?? op.orderId
|
||||
if (String(realOrderId).startsWith('offline_')) {
|
||||
throw new Error('parent order not yet synced')
|
||||
}
|
||||
|
||||
if (op.type === 'pay_real') {
|
||||
// All real item ids — straightforward
|
||||
await client.post(`/api/orders/${realOrderId}/pay-offline`, {
|
||||
uuid: op.uuid,
|
||||
item_ids: op.itemIds,
|
||||
payment_method: op.paymentMethod,
|
||||
offline_at: op.offlineAt,
|
||||
})
|
||||
await db.offline_ops.update(op.localId, { synced: 1 })
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve local item ids
|
||||
const resolvedLocal = (op.localItemIds || []).map(lid => idMap[lid] ?? lid)
|
||||
const stillLocal = resolvedLocal.filter(id => String(id).startsWith('offline_item_'))
|
||||
if (stillLocal.length > 0) {
|
||||
throw new Error('offline item ids not yet resolved')
|
||||
}
|
||||
|
||||
const allIds = [...resolvedLocal, ...(op.realItemIds || [])]
|
||||
await client.post(`/api/orders/${realOrderId}/pay-offline`, {
|
||||
uuid: op.uuid,
|
||||
item_ids: allIds,
|
||||
payment_method: op.paymentMethod,
|
||||
offline_at: op.offlineAt,
|
||||
})
|
||||
await db.offline_ops.update(op.localId, { synced: 1 })
|
||||
}
|
||||
|
||||
// ─── Kept for SSEContext compat ───────────────────────────────────────────────
|
||||
|
||||
export async function flushOfflineOrders() {
|
||||
// no-op — handled by flushAllOfflineOps
|
||||
}
|
||||
|
||||
export async function flushOfflineOps() {
|
||||
// no-op — handled by flushAllOfflineOps
|
||||
}
|
||||
|
||||
export async function pendingOrderCount() {
|
||||
const ops = await db.offline_ops.toArray()
|
||||
return ops.filter(o => !o.synced).length
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
|
||||
/**
|
||||
* Queue an emergency payment locally.
|
||||
* Called in Emergency Mode when the server is unreachable.
|
||||
*/
|
||||
function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
export async function queueOfflinePayment({ orderId, itemIds, paymentMethod }) {
|
||||
const uuid = crypto.randomUUID()
|
||||
const uuid = generateUUID()
|
||||
await db.offline_payments.add({
|
||||
uuid,
|
||||
orderId,
|
||||
|
||||
86
waiter_pwa/src/store/chatStore.js
Normal file
86
waiter_pwa/src/store/chatStore.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
function computeUnread(conversations) {
|
||||
return conversations.reduce((sum, c) => sum + (c.unread_count || 0), 0)
|
||||
}
|
||||
|
||||
const useChatStore = create((set, get) => ({
|
||||
conversations: [],
|
||||
totalUnread: 0,
|
||||
// Messages cache: { [convId]: MessageOut[] } — newest first (API order)
|
||||
messages: {},
|
||||
|
||||
setConversations(list) {
|
||||
set({ conversations: list, totalUnread: computeUnread(list) })
|
||||
},
|
||||
|
||||
upsertConversation(conv) {
|
||||
set(state => {
|
||||
const exists = state.conversations.find(c => c.id === conv.id)
|
||||
const next = exists
|
||||
? state.conversations.map(c => c.id === conv.id ? { ...c, ...conv } : c)
|
||||
: [conv, ...state.conversations]
|
||||
return { conversations: next, totalUnread: computeUnread(next) }
|
||||
})
|
||||
},
|
||||
|
||||
updateLastRead(convId, userId, readAt) {
|
||||
set(state => {
|
||||
const next = state.conversations.map(c => {
|
||||
if (c.id !== convId) return c
|
||||
const participants = (c.participants || []).map(p =>
|
||||
p.user_id === userId ? { ...p, last_read_at: readAt } : p
|
||||
)
|
||||
return { ...c, participants }
|
||||
})
|
||||
return { conversations: next, totalUnread: computeUnread(next) }
|
||||
})
|
||||
},
|
||||
|
||||
addMessage(convId, msg) {
|
||||
set(state => {
|
||||
const existing = state.messages[convId] || []
|
||||
// Prepend (newest-first to match API order), deduplicate by id
|
||||
const deduplicated = existing.some(m => m.id === msg.id)
|
||||
? existing
|
||||
: [msg, ...existing]
|
||||
return { messages: { ...state.messages, [convId]: deduplicated } }
|
||||
})
|
||||
},
|
||||
|
||||
setMessages(convId, msgs) {
|
||||
set(state => ({
|
||||
messages: { ...state.messages, [convId]: msgs },
|
||||
}))
|
||||
},
|
||||
|
||||
prependMessages(convId, olderMsgs) {
|
||||
// Prepend older messages at the end (they're older so go to the "bottom" of the newest-first list)
|
||||
set(state => {
|
||||
const existing = state.messages[convId] || []
|
||||
const existingIds = new Set(existing.map(m => m.id))
|
||||
const newOnes = olderMsgs.filter(m => !existingIds.has(m.id))
|
||||
return { messages: { ...state.messages, [convId]: [...existing, ...newOnes] } }
|
||||
})
|
||||
},
|
||||
|
||||
incrementUnread(convId) {
|
||||
set(state => {
|
||||
const next = state.conversations.map(c =>
|
||||
c.id === convId ? { ...c, unread_count: (c.unread_count || 0) + 1 } : c
|
||||
)
|
||||
return { conversations: next, totalUnread: computeUnread(next) }
|
||||
})
|
||||
},
|
||||
|
||||
markConvRead(convId) {
|
||||
set(state => {
|
||||
const next = state.conversations.map(c =>
|
||||
c.id === convId ? { ...c, unread_count: 0 } : c
|
||||
)
|
||||
return { conversations: next, totalUnread: computeUnread(next) }
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
export default useChatStore
|
||||
@@ -1,57 +1,77 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Tracks the live connection state and emergency mode flag.
|
||||
* Tracks live connection state.
|
||||
*
|
||||
* States:
|
||||
* '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
|
||||
* status:
|
||||
* 'online' — server reachable, SSE connected, normal operation
|
||||
* 'reconnecting' — blip detected, grace period (amber bar)
|
||||
* 'offline' — confirmed unreachable, working from local DB (red bar)
|
||||
*
|
||||
* The old 'emergency' and 'lost' states are gone. The app now enters offline
|
||||
* mode automatically — no user prompt needed.
|
||||
*
|
||||
* After 5 minutes offline the bar flashes to signal a serious problem.
|
||||
*/
|
||||
|
||||
const GRACE_MS = 5_000
|
||||
const GRACE_MS = 4_000 // how long to wait before declaring offline
|
||||
const FLASH_MS = 5 * 60 * 1000 // 5 minutes before bar starts flashing
|
||||
|
||||
const useConnectionStore = create((set, get) => ({
|
||||
status: 'online', // 'online' | 'reconnecting' | 'lost' | 'emergency'
|
||||
lostAt: null,
|
||||
status: 'online', // 'online' | 'reconnecting' | 'offline'
|
||||
sseAlive: false, // true when SSE stream is actively connected
|
||||
lostAt: null, // Date when connection was first lost
|
||||
flashing: false, // true when offline > 5 min
|
||||
_graceTimer: null,
|
||||
_flashTimer: null,
|
||||
|
||||
setSseAlive: (alive) => set({ sseAlive: alive }),
|
||||
|
||||
setLost: () => {
|
||||
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 === 'offline') return
|
||||
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 })
|
||||
}
|
||||
const graceTimer = setTimeout(() => {
|
||||
if (get().status !== 'reconnecting') return
|
||||
const lostAt = get().lostAt
|
||||
|
||||
// Start the 5-min flash timer
|
||||
const flashTimer = setTimeout(() => {
|
||||
if (get().status === 'offline') set({ flashing: true })
|
||||
}, FLASH_MS)
|
||||
|
||||
set({ status: 'offline', _graceTimer: null, _flashTimer: flashTimer })
|
||||
}, GRACE_MS)
|
||||
|
||||
set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: timer })
|
||||
set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: graceTimer })
|
||||
},
|
||||
|
||||
setOnline: () => {
|
||||
const { _graceTimer } = get()
|
||||
const { _graceTimer, _flashTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'online', lostAt: null, _graceTimer: null })
|
||||
if (_flashTimer) clearTimeout(_flashTimer)
|
||||
set({ status: 'online', lostAt: null, flashing: false, _graceTimer: null, _flashTimer: null })
|
||||
},
|
||||
|
||||
// Convenience accessors
|
||||
isOnline: () => get().status === 'online',
|
||||
isOffline: () => get().status === 'offline' || get().status === 'reconnecting',
|
||||
|
||||
// Legacy compat — SSEContext still calls these
|
||||
enterEmergency: () => {
|
||||
const { _graceTimer } = get()
|
||||
// treated as manual offline entry (immediate, no grace period)
|
||||
const { _graceTimer, _flashTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'emergency', _graceTimer: null })
|
||||
if (_flashTimer) clearTimeout(_flashTimer)
|
||||
const flashTimer = setTimeout(() => {
|
||||
if (get().status === 'offline') set({ flashing: true })
|
||||
}, FLASH_MS)
|
||||
set({ status: 'offline', lostAt: new Date(), flashing: false, _graceTimer: null, _flashTimer: flashTimer })
|
||||
},
|
||||
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null, _graceTimer: null }),
|
||||
|
||||
isOnline: () => get().status === 'online',
|
||||
isLost: () => get().status === 'lost',
|
||||
isEmergency: () => get().status === 'emergency',
|
||||
exitEmergency: () => get().setOnline(),
|
||||
isEmergency: () => get().status === 'offline',
|
||||
isLost: () => get().status === 'offline',
|
||||
}))
|
||||
|
||||
export default useConnectionStore
|
||||
|
||||
28
waiter_pwa/src/store/kdsReadyStore.js
Normal file
28
waiter_pwa/src/store/kdsReadyStore.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand'
|
||||
import db from '../db/posdb'
|
||||
|
||||
const useKdsReadyStore = create((set) => ({
|
||||
readyCount: 0,
|
||||
|
||||
// Recompute by scanning IndexedDB orders snapshot
|
||||
async refresh() {
|
||||
try {
|
||||
const orders = await db.pos_orders.toArray()
|
||||
let count = 0
|
||||
for (const o of orders) {
|
||||
if (o.status === 'open' || o.status === 'partially_paid') {
|
||||
for (const item of o.items || []) {
|
||||
if (item.status !== 'cancelled' && item.kds_status === 'done') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set({ readyCount: count })
|
||||
} catch {
|
||||
// IndexedDB unavailable — leave as-is
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
export default useKdsReadyStore
|
||||
20
waiter_pwa/src/store/notificationSettingsStore.js
Normal file
20
waiter_pwa/src/store/notificationSettingsStore.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
// popupMode: 'all' | 'manager_only' | 'never'
|
||||
// persistentPopup: boolean — if false, auto-dismiss after 3s and mark read on delivery
|
||||
|
||||
const useNotificationSettingsStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
popupMode: 'all',
|
||||
persistentPopup: true,
|
||||
|
||||
setPopupMode: (popupMode) => set({ popupMode }),
|
||||
setPersistentPopup: (persistentPopup) => set({ persistentPopup }),
|
||||
}),
|
||||
{ name: 'notification-settings' }
|
||||
)
|
||||
)
|
||||
|
||||
export default useNotificationSettingsStore
|
||||
14
waiter_pwa/src/store/orderingSettingsStore.js
Normal file
14
waiter_pwa/src/store/orderingSettingsStore.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
const useOrderingSettingsStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
summaryBeforeSend: false,
|
||||
setSummaryBeforeSend: (v) => set({ summaryBeforeSend: v }),
|
||||
}),
|
||||
{ name: 'ordering-settings' }
|
||||
)
|
||||
)
|
||||
|
||||
export default useOrderingSettingsStore
|
||||
15
waiter_pwa/src/store/paymentSettingsStore.js
Normal file
15
waiter_pwa/src/store/paymentSettingsStore.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
// 'off' | 'second_confirm' | 'long_press'
|
||||
const usePaymentSettingsStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
paymentSafety: 'off',
|
||||
setPaymentSafety: (v) => set({ paymentSafety: v }),
|
||||
}),
|
||||
{ name: 'payment-settings' }
|
||||
)
|
||||
)
|
||||
|
||||
export default usePaymentSettingsStore
|
||||
@@ -32,6 +32,18 @@ export const DEFAULT_COLOURS = {
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
// 6th status: items are ready to pick up from kitchen/bar
|
||||
kds_ready: {
|
||||
cardBg: '#5dc936',
|
||||
cardBg2: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.57)',
|
||||
badgeBg2: 'rgba(255,255,255,0.57)',
|
||||
nameText: '#ffffff',
|
||||
nameText2: '#ffffff',
|
||||
badgeText: 'rgba(45,71,36,0.63)',
|
||||
badgeText2:'rgba(45,71,36,0.63)',
|
||||
flash: true,
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
free: {
|
||||
@@ -64,6 +76,17 @@ export const DEFAULT_COLOURS = {
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
kds_ready: {
|
||||
cardBg: '#5dc936',
|
||||
cardBg2: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.57)',
|
||||
badgeBg2: 'rgba(255,255,255,0.57)',
|
||||
nameText: '#ffffff',
|
||||
nameText2: '#ffffff',
|
||||
badgeText: 'rgba(45,71,36,0.63)',
|
||||
badgeText2:'rgba(45,71,36,0.63)',
|
||||
flash: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -73,8 +96,6 @@ const useTableColourStore = create((set) => ({
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.light && parsed?.dark) {
|
||||
// Deep-merge so any status keys added after the settings were saved
|
||||
// (e.g. 'paid') still fall back to their defaults.
|
||||
const merged = { light: {}, dark: {} }
|
||||
for (const mode of ['light', 'dark']) {
|
||||
for (const status of Object.keys(DEFAULT_COLOURS[mode])) {
|
||||
|
||||
@@ -15,12 +15,42 @@ const useTableViewStore = create(
|
||||
statusFilter: 'all',
|
||||
zoneFilter: [], // array of zone ids (serialized fine in JSON)
|
||||
activeZoneTab: 'all',
|
||||
chipStyle: 'icon_short', // 'full' | 'short' | 'icon_full' | 'icon_short' | 'icon'
|
||||
tabSwitcherStyle: 'text', // 'text' | 'icon'
|
||||
|
||||
// Footer nav settings
|
||||
// navStyle: 'triple' | 'icon_navbar'
|
||||
navStyle: 'triple',
|
||||
// navItems: array of item keys for icon_navbar mode (1-5 items)
|
||||
// Available keys: 'tables' | 'orders' | 'messages'
|
||||
navItems: ['tables', 'orders', 'messages'],
|
||||
// tripleItems: ordered keys for triple pill mode (exactly 3)
|
||||
tripleItems: ['tables', 'orders', 'messages'],
|
||||
|
||||
setDensity: (density) => set({ density }),
|
||||
setOwnerFilter: (ownerFilter) => set({ ownerFilter }),
|
||||
setStatusFilter: (statusFilter) => set({ statusFilter }),
|
||||
setZoneFilter: (zoneFilter) => set({ zoneFilter }),
|
||||
setActiveZoneTab: (activeZoneTab) => set({ activeZoneTab }),
|
||||
setChipStyle: (chipStyle) => set({ chipStyle }),
|
||||
setTabSwitcherStyle: (tabSwitcherStyle) => set({ tabSwitcherStyle }),
|
||||
setNavStyle: (navStyle) => set({ navStyle }),
|
||||
setNavItems: (navItems) => set({ navItems }),
|
||||
setTripleItems: (tripleItems) => set({ tripleItems }),
|
||||
// KDS status display: 'badge' (default) | 'icon'
|
||||
kdsDisplayStyle: 'badge',
|
||||
setKdsDisplayStyle: (kdsDisplayStyle) => set({ kdsDisplayStyle }),
|
||||
|
||||
// Order item layout: 'compact' | 'expanded' | 'fully_detailed'
|
||||
orderItemLayout: 'compact',
|
||||
setOrderItemLayout: (orderItemLayout) => set({ orderItemLayout }),
|
||||
|
||||
// Merge view persistence: 'off' | 'per_order' | 'global'
|
||||
mergePersist: 'off',
|
||||
setMergePersist: (mergePersist) => set({ mergePersist }),
|
||||
// Stored merge mode for 'global' persistence
|
||||
globalMergeMode: 'off',
|
||||
setGlobalMergeMode: (globalMergeMode) => set({ globalMergeMode }),
|
||||
|
||||
clearFilters: () => set({
|
||||
ownerFilter: 'all',
|
||||
|
||||
101
waiter_pwa/src/store/waiterFavoritesStore.js
Normal file
101
waiter_pwa/src/store/waiterFavoritesStore.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import client from '../api/client'
|
||||
|
||||
const SETTINGS_KEY = 'waiter-favorites'
|
||||
|
||||
// itemFavorites shape: { [productId]: { quick: id[], ingredients: id[], options: id[], prefs: id[] } }
|
||||
// Each array is an ordered list of IDs to show in that product's Favorites tab.
|
||||
// If a product has no entry here, falls back to manager's is_favorite flags.
|
||||
|
||||
const useWaiterFavoritesStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
favorites: [], // ordered product id list (which products appear in FAVORITES chip)
|
||||
quickNotes: [], // custom quick note strings
|
||||
itemFavorites: {}, // per-product attribute overrides (see shape above)
|
||||
|
||||
toggleFavorite(productId) {
|
||||
set(state => {
|
||||
const exists = state.favorites.includes(productId)
|
||||
const favorites = exists
|
||||
? state.favorites.filter(id => id !== productId)
|
||||
: [...state.favorites, productId]
|
||||
return { favorites }
|
||||
})
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
reorderFavorites(newOrder) {
|
||||
set({ favorites: newOrder })
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
// Replace the attribute favorites for a single product.
|
||||
// Pass null to clear (fall back to manager defaults).
|
||||
setItemFavorites(productId, attrFavs) {
|
||||
set(state => {
|
||||
const next = { ...state.itemFavorites }
|
||||
if (attrFavs == null) {
|
||||
delete next[productId]
|
||||
} else {
|
||||
next[productId] = attrFavs
|
||||
}
|
||||
return { itemFavorites: next }
|
||||
})
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
addQuickNote(note) {
|
||||
if (!note.trim()) return
|
||||
set(state => ({ quickNotes: [...state.quickNotes, note.trim()] }))
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
removeQuickNote(idx) {
|
||||
set(state => ({ quickNotes: state.quickNotes.filter((_, i) => i !== idx) }))
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
set({ favorites: [], quickNotes: [], itemFavorites: {} })
|
||||
get()._sync()
|
||||
},
|
||||
|
||||
loadFromServer: async () => {
|
||||
try {
|
||||
const res = await client.get('/api/auth/me/settings')
|
||||
const raw = res.data?.settings
|
||||
if (!raw || raw === '{}') return
|
||||
const parsed = JSON.parse(raw)
|
||||
const slice = parsed[SETTINGS_KEY]
|
||||
if (slice) {
|
||||
set({
|
||||
favorites: slice.favorites ?? [],
|
||||
quickNotes: slice.quickNotes ?? [],
|
||||
itemFavorites: slice.itemFavorites ?? {},
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
} catch { /* offline / not authed */ }
|
||||
},
|
||||
|
||||
_sync: async () => {
|
||||
try {
|
||||
let existing = {}
|
||||
try {
|
||||
const res = await client.get('/api/auth/me/settings')
|
||||
existing = JSON.parse(res.data?.settings || '{}')
|
||||
} catch {}
|
||||
|
||||
const { favorites, quickNotes, itemFavorites } = get()
|
||||
const merged = { ...existing, [SETTINGS_KEY]: { favorites, quickNotes, itemFavorites } }
|
||||
await client.put('/api/auth/me/settings', { settings: JSON.stringify(merged) })
|
||||
} catch { /* best-effort */ }
|
||||
},
|
||||
}),
|
||||
{ name: SETTINGS_KEY }
|
||||
)
|
||||
)
|
||||
|
||||
export default useWaiterFavoritesStore
|
||||
@@ -7,11 +7,22 @@ export default defineConfig({
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['all'],
|
||||
proxy: {
|
||||
'/api/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
registerType: 'prompt',
|
||||
manifest: {
|
||||
name: 'Xenia',
|
||||
short_name: 'Xenia',
|
||||
@@ -31,7 +42,7 @@ export default defineConfig({
|
||||
runtimeCaching: [],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user