Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc

This commit is contained in:
2026-05-02 21:08:53 +03:00
parent 8e27b7666e
commit c9ad78ec71
50 changed files with 4441 additions and 643 deletions

View File

@@ -1 +1 @@
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}} {"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}},"desktop":{"labels":{"desktop-main":"1440×900 — full operational view, mid-shift"}}}}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Table Grid Densities — SimplePOS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: #f4f4f2;
color: #111315;
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { width: 100vw; height: 100vh; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: #dfe2e6; border-radius: 4px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="ios-frame.jsx"></script>
<script type="text/babel" src="tables-data.jsx"></script>
<script type="text/babel" src="table-cards-densities.jsx"></script>
<script type="text/babel" src="tables-app.jsx"></script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper // DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes. // Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), labels/titles are inline-editable, // Artboards are reorderable (grip-drag), deletable, labels/titles are
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc). // inline-editable, and any artboard can be opened in a fullscreen focus
// State persists to a .design-canvas.state.json sidecar via the host // overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// bridge. No assets, no deps. // via the host bridge. No assets, no deps.
// //
// Usage: // Usage:
// <DesignCanvas> // <DesignCanvas>
@@ -39,17 +39,58 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
'.dc-card{transition:box-shadow .15s,transform .15s}', '.dc-card{transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}', '.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}', '.dc-card *::-webkit-scrollbar{display:none}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}', // Per-artboard header: grip + label on the left, delete/expand on the
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}', // right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}', '.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}', '.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}', '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}', '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;', '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}', '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}',
'.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}', '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
'[data-dc-slot]:hover .dc-expand{opacity:1}', '.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}',
'.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;',
' font-size:12px;font-weight:500}',
'.dc-delete.dc-confirm:hover{background:#b5563a}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n'); ].join('\n');
document.head.appendChild(s); document.head.appendChild(s);
} }
@@ -58,8 +99,9 @@ const DCCtx = React.createContext(null);
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport. // DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, focused // Owns runtime state (per-section order, renamed titles/labels, hidden
// artboard). Order/titles/labels persist to a .design-canvas.state.json // artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved // sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together // arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the // (omelette preview, direct link, downloaded zip). Writes go through the
@@ -115,11 +157,19 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
if (!sid) return; if (!sid) return;
sectionOrder.push(sid); sectionOrder.push(sid);
const persisted = state.sections[sid] || {}; const persisted = state.sections[sid] || {};
const srcIds = []; const abs = [];
React.Children.forEach(sec.props.children, (ab) => { React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return; if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label; const aid = ab.props.id ?? ab.props.label;
if (!aid) return; if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid); srcIds.push(aid);
}); });
@@ -183,11 +233,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null); const vpRef = React.useRef(null);
const worldRef = React.useRef(null); const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 }); const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => { const apply = React.useCallback(() => {
const { x, y, scale } = tf.current; const { x, y, scale } = tf.current;
const el = worldRef.current; const el = worldRef.current;
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@@ -272,6 +359,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
vp.style.cursor = ''; vp.style.cursor = '';
}; };
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false }); vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false }); vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false }); vp.addEventListener('gesturechange', onGestureChange, { passive: false });
@@ -281,6 +398,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
vp.addEventListener('pointerup', onPointerUp); vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp); vp.addEventListener('pointercancel', onPointerUp);
return () => { return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel); vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart); vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange); vp.removeEventListener('gesturechange', onGestureChange);
@@ -336,8 +454,13 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
const all = React.Children.toArray(children); const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard); const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard)); const rest = all.filter((c) => !(c && c.type === DCArtboard));
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
const sec = (ctx && sid && ctx.section(sid)) || {}; const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => { const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
@@ -346,13 +469,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return ( return (
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}> <div data-dc-section={sid}
<div style={{ padding: '0 60px 56px' }}> style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<DCEditable tag="div" value={sec.title ?? title} <div style={{ padding: '0 60px' }}>
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })} <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> <DCEditable tag="div" value={sec.title ?? title}
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>} onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div> </div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}> <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => ( {order.map((k) => (
@@ -360,6 +492,10 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
label={(sec.labels || {})[k] ?? byId[k].props.label} label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))} ))}
</div> </div>
@@ -371,10 +507,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. // DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; } function DCArtboard() { return null; }
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) { function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel; const id = rawId ?? rawLabel;
const ref = React.useRef(null); const ref = React.useRef(null);
const delRef = React.useRef(null);
const [confirming, setConfirming] = React.useState(false);
// Two-click delete: first click arms the button (turns into an inline
// "Delete?" pill), second click commits. Any pointerdown outside the
// button disarms.
React.useEffect(() => {
if (!confirming) return;
const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [confirming]);
// Live drag-reorder: dragged card sticks to cursor; siblings slide into // Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only // their would-be slots in real time via transforms. DOM order only
@@ -440,18 +588,32 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
return ( return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}> <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}> <div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder"> <div className="dc-labelrow">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg> <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div> </div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus"> <div className="dc-btns">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()} <button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> onClick={() => { if (confirming) onDelete(); else setConfirming(true); }}
title={confirming ? 'Click again to delete' : 'Delete'}>
{confirming
? <>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6"/></svg>
Delete?
</>
: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6M5 5.5v3M7 5.5v3"/></svg>}
</button>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div> </div>
</div> </div>
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
<div className="dc-card" <div className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}> style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>} {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
@@ -489,9 +651,14 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => { const goSection = (d) => {
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length]; // Sections whose artboards are all deleted have slotIds:[] — step past
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; // them to the next non-empty section so ↑/↓ doesn't dead-end.
if (first) ctx.setFocus(`${ns}/${first}`); const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
}; };
React.useEffect(() => { React.useEffect(() => {
@@ -548,7 +715,7 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
{ddOpen && ( {ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8, <div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}> boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.map((sid) => ( {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }} <button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer', style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff', background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',

View File

@@ -0,0 +1,375 @@
// Table cards at 5 densities. All share the same data model — each card type
// just renders a subset, sized for fast reading at-a-glance.
const { TABLE_STATUS, TABLE_BADGES } = window;
// ---------- shared bits ----------------------------------------------------
function fmtAmount(n) {
if (n == null || n === 0) return '0.00';
return n.toFixed(2);
}
// Splits "12.34" into ["12", ".34"] so we can typeset cents smaller
function splitAmount(n) {
const s = fmtAmount(n);
const [whole, cents] = s.split('.');
return [whole, '.' + cents];
}
function avatarHash(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function WaiterDot({ name, size = 22, ring }) {
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarHash(name),
color: 'white', fontSize: size * 0.42, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>{initials}</div>
);
}
function StackedAvatars({ waiters, size = 22, ring }) {
if (!waiters?.length) return null;
if (waiters.length >= 3) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{waiters.slice(0, 2).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.35 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
<div style={{
marginLeft: -size * 0.35,
height: size, padding: '0 8px',
borderRadius: size,
background: ring || 'rgba(255,255,255,0.9)',
color: '#1a1a1f', fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>+{waiters.length - 2}</div>
</div>
);
}
return (
<div style={{ display: 'flex' }}>
{waiters.map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.3 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
</div>
);
}
function StatusPill({ status, size = 'md' }) {
const s = TABLE_STATUS[status];
const sizes = {
sm: { h: 18, px: 7, fs: 10 },
md: { h: 22, px: 9, fs: 11 },
lg: { h: 26, px: 11, fs: 12 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', height: z.h, padding: `0 ${z.px}px`,
borderRadius: 4,
background: s.pillBg, color: s.pillFg,
fontSize: z.fs, fontWeight: 800,
letterSpacing: 0.5, textTransform: 'uppercase',
whiteSpace: 'nowrap',
}}>{s.label}</span>
);
}
function BadgeChip({ kind, size = 'md' }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
const sizes = {
sm: { h: 20, fs: 11, ic: 12 },
md: { h: 24, fs: 12, ic: 14 },
lg: { h: 28, fs: 13, ic: 16 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: z.h, padding: '0 8px',
borderRadius: z.h / 2,
background: 'rgba(255,255,255,0.95)',
color: b.tone,
fontSize: z.fs, fontWeight: 700,
}}>
<span style={{ fontSize: z.ic, lineHeight: 1 }}>{b.icon}</span>
{b.label}
</span>
);
}
function BadgeDot({ kind, size = 16 }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
return (
<div title={b.label} style={{
width: size, height: size,
borderRadius: '50%',
background: 'rgba(255,255,255,0.95)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.65,
lineHeight: 1,
}}>{b.icon}</div>
);
}
function Amount({ value, size = 22, color }) {
const [w, c] = splitAmount(value);
return (
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 700,
lineHeight: 1,
color: color || 'inherit',
letterSpacing: -0.5,
}}>
<span style={{ fontSize: size }}>{w}</span>
<span style={{ fontSize: size * 0.55, opacity: 0.85 }}>{c}</span>
</div>
);
}
// ---------- card shell -----------------------------------------------------
// All densities share this shell — just different content + dimensions.
function CardShell({ status, w, h, children, padding }) {
const s = TABLE_STATUS[status];
return (
<div style={{
width: w, height: h,
background: s.bg, color: s.fg,
borderRadius: 14,
padding: padding,
boxShadow: '0 1px 2px rgba(16,20,24,0.05)',
position: 'relative',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
cursor: 'pointer',
transition: 'transform 100ms ease',
}}>{children}</div>
);
}
// ===========================================================================
// 1×1 — tiniest. Just NAME. Status is purely the card color.
// ===========================================================================
function Card1x1({ table, w, h }) {
const t = table;
// Show one badge dot if present (very subtle, top-right)
const badge = t.badges[0];
return (
<CardShell status={t.status} w={w} h={h} padding={10}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1,
}}>{t.name}</div>
{badge && (
<div style={{ position: 'absolute', top: 6, right: 6 }}>
<BadgeDot kind={badge} size={14} />
</div>
)}
</CardShell>
);
}
// ===========================================================================
// 2×1 — wider. NAME + status PILL + maybe one badge dot.
// ===========================================================================
function Card2x1({ table, w, h }) {
const t = table;
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: '100%', gap: 10 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<StatusPill status={t.status} size="sm" />
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 3 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={14} />)}
</div>
)}
</div>
</div>
</CardShell>
);
}
// ===========================================================================
// 2×2 — square. NAME big + status pill + amount + waiter dots + badges
// ===========================================================================
function Card2x2({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', height: '100%', gap: 8 }}>
{/* left column: name + pill (top), amount (bottom) */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ marginTop: 6 }}>
<StatusPill status={t.status} size="sm" />
</div>
<div style={{ marginTop: 'auto', minHeight: 24 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
</div>
{/* right column: badges stacked vertically, bottom-aligned */}
{t.badges.length > 0 && (
<div style={{
display: 'flex', flexDirection: 'column-reverse',
gap: 4, alignItems: 'flex-end',
justifyContent: 'flex-start',
}}>
{t.badges.slice(0, 3).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
</div>
</CardShell>
);
}
// ===========================================================================
// 4×1 — wide horizontal. NAME · AMOUNT · status pill + waiter dots
// ===========================================================================
function Card4x1({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={14}>
<div style={{ display: 'flex', alignItems: 'center', height: '100%', gap: 14 }}>
{/* name */}
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
minWidth: 70,
}}>{t.name}</div>
{/* amount (or spacer) */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
{/* badges */}
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 4 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
{/* status pill */}
<StatusPill status={t.status} size="md" />
</div>
</CardShell>
);
}
// ===========================================================================
// 4×2 — full detail. Name + section + status pill + amount + badges + waiters with names
// ===========================================================================
function Card4x2({ table, w, h }) {
const t = table;
const s = TABLE_STATUS[t.status];
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
// First waiter name (or "Multiple")
const waiterCaption = t.waiters.length === 0
? 'Unassigned'
: t.waiters.length >= 3
? `${t.waiters.length} waiters`
: t.waiters.map(w => w.split(' ')[0]).join(', ');
return (
<CardShell status={t.status} w={w} h={h} padding={16}>
{/* top row: name + section + status pill | amount */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 38,
letterSpacing: -1.5, lineHeight: 1,
}}>{t.name}</div>
<div style={{
fontSize: 11, fontWeight: 700,
opacity: 0.7,
textTransform: 'uppercase', letterSpacing: 0.8,
marginTop: 4,
}}>{t.section}</div>
<div style={{ marginTop: 8 }}>
<StatusPill status={t.status} size="lg" />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{showAmount && <Amount value={t.amount} size={38} />}
</div>
</div>
{/* badges block — right-aligned, up to 4 in 2×2 grid, sits above waiter line */}
<div style={{
marginTop: 'auto',
display: 'flex', justifyContent: 'flex-end',
paddingBottom: 10,
minHeight: 24,
}}>
{t.badges.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, max-content)',
gridAutoRows: 'min-content',
gap: 6,
justifyItems: 'end',
direction: 'rtl', // fill right column first, then wrap left
}}>
{t.badges.slice(0, 4).map(b => (
<div key={b} style={{ direction: 'ltr' }}>
<BadgeChip kind={b} size="sm" />
</div>
))}
</div>
)}
</div>
{/* bottom: waiters with names */}
<div style={{
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.18)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
{t.waiters.length === 0 ? (
<span style={{ fontSize: 13, opacity: 0.7, fontWeight: 500 }}>Unassigned</span>
) : (
<>
<StackedAvatars waiters={t.waiters} size={26} ring={s.bg} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{waiterCaption}</span>
</>
)}
</div>
</CardShell>
);
}
window.TableCards = { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 };

View File

@@ -0,0 +1,167 @@
// Wrapping screens — phone frame with the grid at each density
const { IOSDevice } = window;
const { TABLES } = window;
const { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 } = window.TableCards;
const { DesignCanvas, DCSection, DCArtboard } = window;
// Density specs — each one has a column count, gap, and a card renderer.
// "1x1" means 4 columns of tiny squares; "4x2" means 1 large card per row.
//
// The naming reflects relative density: 1x1 = highest density (smallest cards),
// 4x2 = lowest density (biggest, most info).
const DENSITIES = {
'1x1': {
label: '1×1 — Highest density',
desc: 'Just the name. Status as color.',
cols: 4, gap: 8,
aspectW: 1, aspectH: 1,
Card: Card1x1,
},
'2x1': {
label: '2×1 — Compact',
desc: 'Name + status pill.',
cols: 2, gap: 10,
aspectW: 2, aspectH: 1,
Card: Card2x1,
},
'2x2': {
label: '2×2 — Balanced',
desc: 'Name, status, amount, waiters.',
cols: 2, gap: 12,
aspectW: 1, aspectH: 1,
Card: Card2x2,
},
'4x1': {
label: '4×1 — Wide row',
desc: 'Name, amount, status, waiters.',
cols: 1, gap: 10,
aspectW: 4, aspectH: 1,
Card: Card4x1,
},
'4x2': {
label: '4×2 — Full detail',
desc: 'Everything. Section, badges, waiter names.',
cols: 1, gap: 12,
aspectW: 2, aspectH: 1,
Card: Card4x2,
},
};
// Top filter bar
function FilterBar() {
const filters = [
{ label: 'All', active: true },
{ label: 'Mine' },
{ label: 'Free' },
{ label: 'Zone (2)' },
];
return (
<div style={{
padding: '10px 16px 14px',
background: 'white',
display: 'flex', gap: 8,
borderBottom: '1px solid #edeff1',
}}>
{filters.map(f => (
<button key={f.label} style={{
height: 38, padding: '0 16px',
borderRadius: 10,
background: f.active ? '#f5b740' : 'white',
border: '1.5px solid ' + (f.active ? '#f5b740' : '#dfe2e6'),
color: f.active ? '#3a2a05' : '#5a6169',
fontSize: 14, fontWeight: 700,
fontFamily: 'inherit',
cursor: 'pointer',
}}>{f.label}</button>
))}
</div>
);
}
function Header({ density }) {
return (
<div style={{
padding: '54px 16px 10px',
background: 'white',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{ flex: 1, fontSize: 20, fontWeight: 700, color: '#111315' }}>Tables</div>
<button style={{
width: 38, height: 38,
borderRadius: 19, border: '1px solid #dfe2e6', background: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 22C13.1 22 14 21.1 14 20H10C10 21.1 10.9 22 12 22ZM18 16V11C18 7.9 16.4 5.4 13.5 4.7V4C13.5 3.2 12.8 2.5 12 2.5C11.2 2.5 10.5 3.2 10.5 4V4.7C7.6 5.4 6 7.9 6 11V16L4 18V19H20V18L18 16Z" stroke="#2b2f33" strokeWidth="1.6"/>
</svg>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#2b2f33' }}>
dimitris
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="#5a6169" strokeWidth="2"/></svg>
</div>
</div>
);
}
function DensityScreen({ densityKey }) {
const d = DENSITIES[densityKey];
// Compute card width: phone interior is ~370px wide, padding 12px each side
const padding = 12;
const innerW = 370 - padding * 2;
const cardW = (innerW - d.gap * (d.cols - 1)) / d.cols;
const cardH = cardW * (d.aspectH / d.aspectW);
return (
<div style={{
width: '100%', height: '100%',
background: '#f4f4f2',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
<Header density={d.label} />
<FilterBar />
<div style={{ flex: 1, overflowY: 'auto', padding: padding }}>
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${d.cols}, 1fr)`,
gap: d.gap,
}}>
{TABLES.map(t => (
<d.Card key={t.name} table={t} w={cardW} h={cardH} />
))}
</div>
</div>
</div>
);
}
function App() {
const order = ['1x1', '2x1', '2x2', '4x1', '4x2'];
return (
<DesignCanvas title="Table grid — 5 density options">
<DCSection id="densities" title="Density variants — selectable in user settings">
{order.map(k => {
const d = DENSITIES[k];
return (
<DCArtboard key={k} id={k} label={d.label + ' — ' + d.desc} width={460} height={920}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '100%', height: '100%',
background: 'transparent',
}}>
<IOSDevice>
<DensityScreen densityKey={k} />
</IOSDevice>
</div>
</DCArtboard>
);
})}
</DCSection>
</DesignCanvas>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View File

@@ -0,0 +1,47 @@
// Table grid data + status palette
// Statuses — bold colors, high contrast for fast reading
const TABLE_STATUS = {
free: { label: 'Free', bg: '#e9ebee', fg: '#3a3f45', pillBg: '#d3d6db', pillFg: '#3a3f45' },
open: { label: 'Open', bg: '#f5b740', fg: '#3a2a05', pillBg: '#3a2a05', pillFg: '#ffe7b2' },
partial: { label: 'Partial', bg: '#3b86e6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
paid: { label: 'Paid', bg: '#3aa961', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
reserved: { label: 'Reserved', bg: '#8b5cd6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
attention: { label: 'Needs you', bg: '#e64545', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.3)', pillFg: '#ffffff' },
mine: { label: 'Mine', bg: '#1f1f24', fg: '#ffffff', pillBg: '#f5b740', pillFg: '#3a2a05' },
};
// Badge dictionary — icon + tone for each flag
const TABLE_BADGES = {
cleaning: { label: 'Cleaning', icon: '🧹', tone: '#8a6d2b' },
waiter: { label: 'Waiter', icon: '🔔', tone: '#d94b26' },
vip: { label: 'VIP', icon: '⭐', tone: '#a76b00' },
allergy: { label: 'Allergy', icon: '⚠', tone: '#a5361b' },
birthday: { label: 'Birthday', icon: '🎂', tone: '#a8276b' },
};
// 24 demo tables across statuses + sections
const TABLES = [
{ name: 'A-1', section: 'Terrace', status: 'open', amount: 84.50, waiters: ['Marco Riva'], badges: [] },
{ name: 'A-2', section: 'Terrace', status: 'mine', amount: 127.20, waiters: ['You'], badges: ['vip'] },
{ name: 'A-3', section: 'Terrace', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'A-4', section: 'Terrace', status: 'attention', amount: 56.00, waiters: ['Luca'], badges: ['waiter'] },
{ name: 'A-5', section: 'Terrace', status: 'reserved', amount: 0, waiters: ['Elena'], badges: ['birthday'] },
{ name: 'A-6', section: 'Terrace', status: 'paid', amount: 0, waiters: ['Marco Riva'], badges: [] },
{ name: 'B-1', section: 'Hall', status: 'partial', amount: 38.00, waiters: ['Sofia'], badges: [] },
{ name: 'B-2', section: 'Hall', status: 'open', amount: 212.80, waiters: ['Marco', 'Sofia', 'Luca', 'Elena'], badges: ['vip', 'allergy', 'birthday', 'waiter'] },
{ name: 'B-3', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: ['cleaning'] },
{ name: 'B-4', section: 'Hall', status: 'mine', amount: 16.30, waiters: ['You', 'Billy'], badges: [] },
{ name: 'B-5', section: 'Hall', status: 'open', amount: 72.80, waiters: ['Sofia'], badges: ['allergy'] },
{ name: 'B-6', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'C-1', section: 'Bar', status: 'partial', amount: 24.50, waiters: ['Elena'], badges: [] },
{ name: 'C-2', section: 'Bar', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'C-3', section: 'Bar', status: 'paid', amount: 0, waiters: ['Luca'], badges: [] },
{ name: 'C-4', section: 'Bar', status: 'reserved', amount: 0, waiters: ['Sofia'], badges: [] },
];
window.TABLE_STATUS = TABLE_STATUS;
window.TABLE_BADGES = TABLE_BADGES;
window.TABLES = TABLES;

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -26,6 +26,7 @@ from routers import shifts as shifts_router
from routers import settings as settings_router from routers import settings as settings_router
from routers import flags as flags_router from routers import flags as flags_router
from routers import messages as messages_router from routers import messages as messages_router
from routers import sse as sse_router
def _run_migrations(): def _run_migrations():
@@ -111,10 +112,13 @@ def _run_migrations():
name VARCHAR NOT NULL, name VARCHAR NOT NULL,
emoji VARCHAR, emoji VARCHAR,
color VARCHAR DEFAULT '#6b7280', color VARCHAR DEFAULT '#6b7280',
text_color VARCHAR DEFAULT NULL,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""", )""",
# Migration: add text_color if upgrading from older schema
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
"""CREATE TABLE IF NOT EXISTS table_flag_assignments ( """CREATE TABLE IF NOT EXISTS table_flag_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id), table_id INTEGER NOT NULL REFERENCES tables(id),
@@ -181,6 +185,21 @@ def _run_migrations():
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'", "ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
# Compact (half-width) display flag for quick options # Compact (half-width) display flag for quick options
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0", "ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
# Print layout + per-type font settings
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
# Offline/emergency payment tracking
"ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR",
"ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR",
"ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0",
] ]
for sql in migrations: for sql in migrations:
try: try:
@@ -193,6 +212,9 @@ def _run_migrations():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
init_loop(asyncio.get_running_loop())
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations() _run_migrations()
sync_task = await start_cloud_sync() sync_task = await start_cloud_sync()
@@ -232,3 +254,4 @@ app.include_router(shifts_router.router, prefix="/api/shifts", tag
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"]) app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"]) app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])

View File

@@ -15,7 +15,8 @@ class TableFlagDef(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
emoji = Column(String, nullable=True) emoji = Column(String, nullable=True)
color = Column(String, nullable=True, default="#6b7280") # hex color = Column(String, nullable=True, default="#6b7280") # hex background
text_color = Column(String, nullable=True, default=None) # hex text; None = white
sort_order = Column(Integer, default=0, nullable=False) sort_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow) created_at = Column(DateTime(timezone=True), default=_utcnow)

View File

@@ -93,13 +93,17 @@ class OrderAuditLog(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
event_type = Column(String, nullable=False) event_type = Column(String, nullable=False)
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED # ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True) waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED) item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
amount = Column(Float, nullable=True) # total value for PAYMENT events amount = Column(Float, nullable=True) # total value for PAYMENT events
payment_method = Column(String, nullable=True) payment_method = Column(String, nullable=True)
note = Column(Text, nullable=True) note = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow) created_at = Column(DateTime(timezone=True), default=_utcnow)
# Emergency offline payment fields
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
offline_at = Column(String, nullable=True) # ISO timestamp from client
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
order = relationship("Order", back_populates="audit_logs") order = relationship("Order", back_populates="audit_logs")
waiter = relationship("User") waiter = relationship("User")

View File

@@ -0,0 +1,137 @@
"""
Font size comparison test — Jolimark TP850UE
Usage: python print_size_test.py [IP] [PORT]
Default: 10.98.20.25:9100
Prints a single page showing all available size options side by side,
to help decide which sizes to expose in the settings UI.
Hardware facts:
ESC ! (0x1B 0x21 n):
0x10 = double-height only (tall + narrow — breaks aspect ratio)
0x20 = double-width only (short + wide — breaks aspect ratio)
0x30 = double-height + double-width (2x in both axes — correct aspect ratio)
There is NO 1.5x in ESC/POS hardware.
GS ! (0x1D 0x21 n) can go 3x, 4x … 8x but they are extremely large.
"""
import sys
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
try:
from escpos.printer import Network
except ImportError:
print("escpos not installed. Run: pip install python-escpos")
sys.exit(1)
def gr(text):
return text.encode('cp737', errors='replace')
def raw(p, b):
p._raw(b)
def section(p, title):
raw(p, b'\x1b\x21\x00')
raw(p, b'\x1b\x45\x00')
raw(p, b'\x1b\x61\x01')
p._raw(gr(f"--- {title} ---\n"))
raw(p, b'\x1b\x61\x00')
def print_sample(p, esc_bang, gs_size, label_en, label_gr):
"""Print one size sample with label."""
# Label at normal size
raw(p, b'\x1b\x21\x00')
raw(p, b'\x1b\x45\x00')
p._raw(gr(f"{label_en}:\n"))
# Apply size via ESC ! and/or GS !
if gs_size is not None:
raw(p, bytes([0x1d, 0x21, gs_size]))
raw(p, bytes([0x1b, 0x21, esc_bang]))
p._raw(gr(f"Club Sandwich. x1\n"))
p._raw(gr(f"* Χωρις αλατι\n"))
p._raw(gr(f"+ Extra Bacon x2\n"))
# Reset
raw(p, b'\x1d\x21\x00')
raw(p, b'\x1b\x21\x00')
raw(p, b'\n')
def divider(p):
raw(p, b'\x1b\x21\x00')
p._raw(gr("-" * 48 + "\n"))
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}...")
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
raw(p, b'\x1b\x40') # ESC @ reset
raw(p, b'\x1b\x74\x1d') # CP737 Greek
raw(p, b'\x1b\x61\x01')
raw(p, b'\x1b\x21\x30')
raw(p, b'\x1b\x45\x01')
p._raw(gr("SIZE COMPARISON TEST\n"))
raw(p, b'\x1b\x21\x00')
raw(p, b'\x1b\x45\x00')
raw(p, b'\x1b\x61\x00')
p._raw(gr("Which sizes look good for ticket printing?\n\n"))
# ── Section 1: The two aspect-ratio-correct options ───────────────────────
section(p, "CORRECT ASPECT RATIO")
p._raw(gr("\n"))
print_sample(p,
esc_bang=0x00, gs_size=None,
label_en="[1] SMALL (1x1 — normal)",
label_gr="")
print_sample(p,
esc_bang=0x30, gs_size=None,
label_en="[2] LARGE (2x2 — double height+width)",
label_gr="")
# ── Section 2: The broken single-axis options (for comparison) ────────────
divider(p)
section(p, "BROKEN ASPECT RATIO (for comparison)")
p._raw(gr("These scale only ONE axis — shown so\nyou can confirm they look wrong.\n\n"))
print_sample(p,
esc_bang=0x10, gs_size=None,
label_en="[3] Tall only (2x height, 1x width)",
label_gr="")
print_sample(p,
esc_bang=0x20, gs_size=None,
label_en="[4] Wide only (1x height, 2x width)",
label_gr="")
# ── Section 3: GS ! options — 3x and beyond ──────────────────────────────
divider(p)
section(p, "GS! LARGER SIZES (3x3, 4x4)")
p._raw(gr("These are technically available but\nvery large. Shown for completeness.\n\n"))
print_sample(p,
esc_bang=0x00, gs_size=0x22,
label_en="[5] GS! 3x3",
label_gr="")
print_sample(p,
esc_bang=0x00, gs_size=0x33,
label_en="[6] GS! 4x4",
label_gr="")
# ── Conclusion ────────────────────────────────────────────────────────────
divider(p)
raw(p, b'\x1b\x61\x01')
raw(p, b'\x1b\x21\x00')
p._raw(gr("CONCLUSION:\n"))
p._raw(gr("[1] Small = use for modifiers/notes\n"))
p._raw(gr("[2] Large = use for item names/headers\n"))
p._raw(gr("No true 1.5x exists in hardware.\n"))
p._raw(gr("GS! 3x3/4x4 available if desired.\n"))
raw(p, b'\n\n\n')
p.cut()
p.close()
print("Done.")

View File

@@ -7,6 +7,7 @@ from models.flag import TableFlagDef, TableFlagAssignment
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from models.user import User from models.user import User
from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
@@ -124,9 +125,11 @@ def set_table_flags(
)) ))
db.commit() db.commit()
return db.query(TableFlagAssignment).filter( result = db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id TableFlagAssignment.table_id == table_id
).all() ).all()
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
return result
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
@@ -139,3 +142,4 @@ def clear_table_flags(
TableFlagAssignment.table_id == table_id TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.commit() db.commit()
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})

View File

@@ -11,6 +11,7 @@ from schemas.message import (
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut, QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
) )
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
@@ -113,7 +114,22 @@ def send_message(
db.add(msg) db.add(msg)
db.commit() db.commit()
msg = _load_msg(db, msg.id) msg = _load_msg(db, msg.id)
return _message_out(msg) out = _message_out(msg)
# Broadcast to targeted users (empty list = all connected users)
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
broadcast_sync(
"message_sent",
{
"id": out.id,
"sender_id": out.sender_id,
"sender_name": out.sender_name,
"body": out.body,
"table_ids": out.table_ids,
"created_at": out.created_at.isoformat() if out.created_at else None,
},
user_ids=target_ids,
)
return out
@router.get("/unread", response_model=List[StaffMessageOut]) @router.get("/unread", response_model=List[StaffMessageOut])

View File

@@ -9,7 +9,7 @@ from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.user import User, WaiterZone from models.user import User, WaiterZone
from models.table import Table from models.table import Table
from models.product import Product from models.product import Product
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
from pydantic import BaseModel from pydantic import BaseModel
class PrintOrderRequest(BaseModel): class PrintOrderRequest(BaseModel):
@@ -33,6 +33,7 @@ class MoveItemsRequest(BaseModel):
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
@@ -159,6 +160,7 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id) _audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
return order return order
@@ -209,7 +211,7 @@ def add_items(
db.refresh(order) db.refresh(order)
print_results = route_and_print_sync(order_id, new_item_ids, db) print_results = route_and_print_sync(order_id, new_item_ids, db)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
return {"order": order, "print_results": print_results} return {"order": order, "print_results": print_results}
@@ -295,6 +297,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids, _audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
amount=total_paid, payment_method=body.payment_method) amount=total_paid, payment_method=body.payment_method)
db.commit() db.commit()
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
return {"status": order.status, "paid_item_ids": paid_ids} return {"status": order.status, "paid_item_ids": paid_ids}
@@ -312,9 +315,105 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id) _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit() db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
return {"status": "closed"} return {"status": "closed"}
@router.post("/{order_id}/pay-offline")
def pay_items_offline(
order_id: int,
body: OfflinePaymentRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Sync an emergency payment that was taken while the server was offline.
The UUID prevents double-processing. If a payment with the same UUID already
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
than silently dropped — so managers can reconcile.
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
# Check for duplicate UUID on this order
existing_uuid = db.query(OrderAuditLog).filter(
OrderAuditLog.order_id == order_id,
OrderAuditLog.offline_uuid == body.uuid,
).first()
is_duplicate = existing_uuid is not None
from models.shift import WaiterShift
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
# Reject empty payments — client had no offline snapshot for this table
if not items and not is_duplicate:
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
try:
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
except (ValueError, AttributeError):
paid_at = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
total_paid = 0.0
paid_ids = []
if not is_duplicate:
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = paid_at
item.payment_method = body.payment_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
paid_ids.append(item.id)
db.flush()
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active"
).count()
order.status = "paid" if active_remaining == 0 else "partially_paid"
else:
# Duplicate — compute total for audit record without changing item state
total_paid = sum(i.unit_price * i.quantity for i in items)
paid_ids = [i.id for i in items]
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
db.add(OrderAuditLog(
order_id=order_id,
event_type="PAYMENT_OFFLINE",
waiter_id=user.id,
item_ids=json.dumps(paid_ids),
amount=total_paid,
payment_method=body.payment_method,
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
offline_uuid=body.uuid,
offline_at=body.offline_at,
is_duplicate=1 if is_duplicate else 0,
))
db.commit()
if not is_duplicate:
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
return {
"status": order.status if not is_duplicate else "duplicate",
"paid_item_ids": paid_ids,
"is_duplicate": is_duplicate,
}
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
order = db.query(Order).filter(Order.id == order_id).first() order = db.query(Order).filter(Order.id == order_id).first()
@@ -325,6 +424,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id) _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit() db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
@router.put("/{order_id}/assign-waiter") @router.put("/{order_id}/assign-waiter")
@@ -444,6 +544,7 @@ def transfer_order(
note=f"Transferred from table {old_table_id} to table {body.target_table_id}") note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
db.commit() db.commit()
db.refresh(order) db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
return order return order
@@ -517,6 +618,8 @@ def merge_order(
db.commit() db.commit()
db.refresh(target) db.refresh(target)
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
return target return target

View File

@@ -17,13 +17,19 @@ VALID_SETTINGS = {
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.", "system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.", "ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.", "dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
# Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1 # Print layout
"print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')", "print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
"print.font_options": "Font for option/modifier lines: SIZE:BOLD",
"print.font_table": "Font for table/waiter header lines: SIZE:BOLD",
"print.font_order_number": "Font for order number header: SIZE:BOLD",
"print.font_header": "Font for top header block: SIZE:BOLD",
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty", "print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
"print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS",
"print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS",
"print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS",
"print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS",
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
} }
DEFAULTS = { DEFAULTS = {
@@ -33,12 +39,17 @@ DEFAULTS = {
"system.timezone": "Europe/Athens", "system.timezone": "Europe/Athens",
"ui.table_colours": "", "ui.table_colours": "",
"dev.spoof_printing": "false", "dev.spoof_printing": "false",
"print.font_item_name": "16:0", # double-height, no bold "print.ticket_mode": "detailed",
"print.font_options": "0:0", # normal
"print.font_table": "16:0", # double-height
"print.font_order_number": "48:1", # double-height + double-width + bold
"print.font_header": "48:1", # double-height + double-width + bold
"print.divider_style": "dash", "print.divider_style": "dash",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
"print.font_quick": "0:0:0",
"print.font_pref": "0:0:0",
"print.font_extra": "0:0:0",
"print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0",
} }

View File

@@ -0,0 +1,60 @@
"""
SSE stream endpoint — one long-lived GET per connected phone.
Authentication: token passed as query param ?token=<jwt>
(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.)
The client receives a stream of JSON lines:
data: {"type": "...", "data": {...}}\n\n
A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts.
"""
import asyncio
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from routers.deps import decode_token
from services.sse_bus import subscribe, unsubscribe
router = APIRouter()
KEEPALIVE_INTERVAL = 25 # seconds
async def _event_stream(user_id: int):
q = await subscribe(user_id)
try:
while True:
try:
payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
yield f"data: {payload}\n\n"
except asyncio.TimeoutError:
# keepalive — prevents nginx/proxies from closing idle connections
yield ": ping\n\n"
except asyncio.CancelledError:
pass
finally:
await unsubscribe(user_id, q)
@router.get("/stream")
async def sse_stream(token: str = Query(...)):
"""
Open an SSE stream for the authenticated user.
The phone connects once on login and stays connected.
On reconnect (after network drop) it does a full GET first, then reconnects here.
"""
# decode_token raises HTTPException on invalid/expired — no manual check needed
payload = decode_token(token)
user_id: int = int(payload["sub"])
return StreamingResponse(
_event_stream(user_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disable nginx buffering
"Connection": "keep-alive",
},
)

View File

@@ -61,6 +61,15 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
return {"success": success, "error": error} return {"success": success, "error": error}
@router.post("/printers/test-order")
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut) @router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()

View File

@@ -12,6 +12,7 @@ from schemas.table import (
TableBatchCreate, TableBatchCreate,
) )
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter() router = APIRouter()
@@ -105,6 +106,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
db.add(table) db.add(table)
db.commit() db.commit()
db.refresh(table) db.refresh(table)
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
return table return table

View File

@@ -7,6 +7,7 @@ class FlagDefCreate(BaseModel):
name: str name: str
emoji: Optional[str] = None emoji: Optional[str] = None
color: Optional[str] = "#6b7280" color: Optional[str] = "#6b7280"
text_color: Optional[str] = None
sort_order: Optional[int] = 0 sort_order: Optional[int] = 0
@@ -14,6 +15,7 @@ class FlagDefUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
emoji: Optional[str] = None emoji: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
text_color: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@@ -23,6 +25,7 @@ class FlagDefOut(BaseModel):
name: str name: str
emoji: Optional[str] = None emoji: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
text_color: Optional[str] = None
sort_order: int sort_order: int
is_active: bool is_active: bool

View File

@@ -9,6 +9,9 @@ class SelectedOptionInput(BaseModel):
name: Optional[str] = None name: Optional[str] = None
price_delta: Optional[float] = None price_delta: Optional[float] = None
extra_cost: Optional[float] = None extra_cost: Optional[float] = None
# type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub"
# Omitted by old clients — print code falls back gracefully.
type: Optional[str] = None
class OrderItemInput(BaseModel): class OrderItemInput(BaseModel):
@@ -73,6 +76,13 @@ class PayItemsRequest(BaseModel):
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
class OfflinePaymentRequest(BaseModel):
uuid: str # client-generated UUID, used for duplicate detection
item_ids: List[int]
payment_method: Optional[str] = None
offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline
class AssignWaiterRequest(BaseModel): class AssignWaiterRequest(BaseModel):
waiter_id: int waiter_id: int
@@ -93,6 +103,8 @@ class AuditLogOut(BaseModel):
payment_method: Optional[str] = None payment_method: Optional[str] = None
note: Optional[str] = None note: Optional[str] = None
created_at: UTCDatetime created_at: UTCDatetime
offline_at: Optional[str] = None
is_duplicate: int = 0
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -54,13 +54,32 @@ _DIVIDER_CHARS = {
"empty": "", "empty": "",
} }
_PRINT_FONT_DEFAULTS = { _PRINT_SETTING_KEYS = [
"print.font_item_name": "16:0", "print.ticket_mode",
"print.font_options": "0:0", "print.divider_style",
"print.font_table": "16:0", "print.font_order_number",
"print.font_order_number": "48:1", "print.font_meta",
"print.font_header": "48:1", "print.font_item_name",
"print.font_quick",
"print.font_pref",
"print.font_extra",
"print.font_ingredient",
"print.font_item_note",
"print.font_order_note",
]
_PRINT_SETTING_DEFAULTS = {
"print.ticket_mode": "detailed",
"print.divider_style": "dash", "print.divider_style": "dash",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
"print.font_quick": "0:0:0",
"print.font_pref": "0:0:0",
"print.font_extra": "0:0:0",
"print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0",
} }
# SIZE byte values (ESC ! base, no bold bit): # SIZE byte values (ESC ! base, no bold bit):
@@ -68,27 +87,28 @@ _PRINT_FONT_DEFAULTS = {
# 16 = double-height (bit4) # 16 = double-height (bit4)
# 32 = double-width (bit5) # 32 = double-width (bit5)
# 48 = double-height + double-width (bits 4+5) # 48 = double-height + double-width (bits 4+5)
# Bold is applied separately via ESC E. # Bold applied via ESC E, caps applied in software before encoding.
def _decode_font(value: str) -> tuple[int, bool]: def _decode_font(value: str) -> tuple[int, bool, bool]:
"""Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag).""" """Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag)."""
try: try:
parts = str(value).split(":") parts = str(value).split(":")
size = int(parts[0]) size = int(parts[0])
bold = len(parts) > 1 and parts[1] == "1" bold = len(parts) > 1 and parts[1] == "1"
return size, bold caps = len(parts) > 2 and parts[2] == "1"
return size, bold, caps
except (ValueError, AttributeError): except (ValueError, AttributeError):
return 0, False return 0, False, False
def _load_print_fonts(db: Session) -> dict: def _load_print_settings(db: Session) -> dict:
rows = db.query(PosSettings).filter( rows = db.query(PosSettings).filter(
PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys()) PosSettings.key.in_(_PRINT_SETTING_KEYS)
).all() ).all()
fonts = dict(_PRINT_FONT_DEFAULTS) settings = dict(_PRINT_SETTING_DEFAULTS)
for row in rows: for row in rows:
fonts[row.key] = row.value settings[row.key] = row.value
return fonts return settings
def _divider(p: Network, style: str = "dash"): def _divider(p: Network, style: str = "dash"):
@@ -100,14 +120,42 @@ def _divider(p: Network, style: str = "dash"):
p._raw(b'\n') p._raw(b'\n')
def _item_line(name: str, qty: int) -> str: def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars.""" """Build a dot-leader line ending with 'xN'.
qty_str = str(qty) line_width must reflect the effective width at the chosen font size
gap = LINE_WIDTH - len(name) - len(qty_str) (double-width fonts halve the available char count to 24)."""
if gap < 3: suffix = f"x{qty}"
return f"{name} {qty_str}" available = line_width - len(name) - len(suffix)
dots = (". " * ((gap // 2) + 1))[:gap] if available < 2:
return f"{name}{dots}{qty_str}" # Name alone is too long — put qty on same line with a single space
return f"{name} {suffix}"
dots = (". " * ((available // 2) + 1))[:available]
return f"{name}{dots}{suffix}"
def _apply_font(p: Network, size: int, bold: bool):
p._raw(bytes([0x1b, 0x21, size]))
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
def _reset_font(p: Network):
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
align: bytes = b'\x1b\x61\x00'):
"""Apply font, optionally capitalize, print text + newline, reset font."""
p._raw(align)
_apply_font(p, size, bold)
out = text.upper() if caps else text
_raw_text(p, out + "\n")
_reset_font(p)
def _greek_date(dt: datetime.datetime) -> str:
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
return dt.strftime("%H:%M %d-%m-%Y")
def check_printer(ip: str, port: int) -> bool: def check_printer(ip: str, port: int) -> bool:
@@ -152,88 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
return False, str(e) return False, str(e)
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
"""Print a fake order using the current font/layout settings — for settings preview."""
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping test order print")
return True, ""
# ── Fake data structures (no DB writes) ──────────────────────────────────
class _Table:
label = "O2"
number = 2
class _User:
nickname = "bonamin"
username = "bonamin"
class _Order:
id = 99
table = _Table()
opener = _User()
table_id = 2
opened_by = 1
notes = "Χωρις καψαλισμα παρακαλω"
class _Item:
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
self.product_id = product_id
self.quantity = quantity
self.selected_options = selected_options
self.removed_ingredients = removed_ingredients
self.notes = notes
import json as _json
items = [
# Item 1: Freddo Espresso — quick options + preference + note
_Item(
product_id=1001,
quantity=2,
selected_options=_json.dumps([
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
]),
removed_ingredients=None,
notes="Πολυ κρυο παρακαλω",
),
# Item 2: Club Sandwich — extra with sub + removed ingredients
_Item(
product_id=1002,
quantity=1,
selected_options=_json.dumps([
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
]),
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
notes=None,
),
# Item 3: Margherita — quick + extra + removed
_Item(
product_id=1003,
quantity=3,
selected_options=_json.dumps([
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
]),
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
notes=None,
),
]
# Patch product lookup so _print_kitchen_ticket gets real names
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
# Monkey-patch db.query for Product only inside this call
_orig_query = db.query
class _FakeQuery:
def __init__(self, model):
self._model = model
self._filter_id = None
def filter(self, *args):
# extract id from the filter expression value
for arg in args:
try:
self._filter_id = arg.right.value
except Exception:
pass
return self
def first(self):
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
class _P:
name = _FAKE_NAMES[self._filter_id]
return _P()
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
class _PatchedDB:
def query(self, model):
from models.product import Product as _Product
if model is _Product:
return _FakeQuery(model)
return _orig_query(model)
# delegate everything else to real db
def __getattr__(self, name):
return getattr(db, name)
try:
p = _get_printer(ip, port)
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
p.close()
return True, ""
except Exception as e:
logger.error("Test order print failed for %s:%s%s", ip, port, e)
return False, str(e)
# ── Receipt formatting ─────────────────────────────────────────────────────── # ── Receipt formatting ───────────────────────────────────────────────────────
def _font(p: Network, byte_val: int, bold: bool = False): def _parse_options(item: OrderItem) -> dict:
p._raw(bytes([0x1b, 0x21, byte_val])) """
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00') Parse selected_options JSON into grouped dict:
{ 'quick': [(name, qty)], 'pref': [(name, sub|None)],
'extra': [(name, sub|None, qty)], 'unknown': [name] }
Falls back gracefully when type tags are absent (old data).
"""
result = {"quick": [], "pref": [], "extra": [], "unknown": []}
if not item.selected_options:
return result
try:
raw = json.loads(item.selected_options)
except (json.JSONDecodeError, TypeError):
return result
if not isinstance(raw, list):
return result
i = 0
while i < len(raw):
entry = raw[i]
if not isinstance(entry, dict):
i += 1
continue
name = entry.get("name") or ""
etype = entry.get("type")
# Peek at next entry to collect sub-choice
sub = None
if i + 1 < len(raw):
nxt = raw[i + 1]
if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"):
sub = nxt.get("name") or ""
i += 1 # consume sub
if etype == "quick":
# Collapse repeated quick entries into a single (name, qty) tuple
existing = next((q for q in result["quick"] if q[0] == name), None)
if existing:
result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1)
else:
result["quick"].append((name, 1))
elif etype == "pref":
result["pref"].append((name, sub))
elif etype == "extra":
# Collapse repeated extra entries (same name+sub) → (name, sub, qty)
existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None)
if existing:
result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1)
else:
result["extra"].append((name, sub, 1))
else:
# Legacy data without type tag — treat as unknown, display plainly
if name:
result["unknown"].append(name + (f" · {sub}" if sub else ""))
i += 1
return result
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session): def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
fonts = _load_print_fonts(db) cfg = _load_print_settings(db)
div = fonts["print.divider_style"] mode = cfg.get("print.ticket_mode", "detailed")
div = cfg.get("print.divider_style", "dash")
compact = (mode == "compact")
sz_order, bold_order = _decode_font(fonts["print.font_order_number"]) sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
sz_table, bold_table = _decode_font(fonts["print.font_table"]) sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
sz_item, bold_item = _decode_font(fonts["print.font_item_name"]) sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
sz_opt, bold_opt = _decode_font(fonts["print.font_options"]) sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
# Header — order number # Resolve display names
p._raw(b'\x1b\x61\x01') table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
_font(p, sz_order, bold_order) waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
_raw_text(p, f"Παραγγελια #{order.id}\n") now_str = _greek_date(datetime.datetime.now())
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_divider(p, div)
# Meta — table / waiter / time # ── COMPACT header — single line ────────────────────────────────────────
p._raw(b'\x1b\x61\x00') if compact:
_font(p, sz_table, bold_table) p._raw(b'\x1b\x61\x00')
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") _apply_font(p, sz_ord, b_ord)
_raw_text(p, f"Date: {now}\n") header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
_raw_text(p, f"Table: {order.table_id}\n") _raw_text(p, (header.upper() if c_ord else header) + "\n")
_raw_text(p, f"Waiter: {order.opened_by}\n") _reset_font(p)
p._raw(b'\x1b\x21\x00') _divider(p, div)
p._raw(b'\x1b\x45\x00')
_divider(p, div) # ── DETAILED header ──────────────────────────────────────────────────────
else:
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
align=b'\x1b\x61\x01')
_divider(p, div)
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_meta, b_meta)
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
_reset_font(p)
_divider(p, div)
# ── Items ────────────────────────────────────────────────────────────────
# Double-width fonts halve the effective character width
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
# Items
for item in items: for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first() product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}" raw_name = product.name if product else f"Product #{item.product_id}"
item_name = raw_name.upper() if c_item else raw_name
_font(p, sz_item, bold_item) p._raw(b'\x1b\x61\x00')
_raw_text(p, _item_line(name, item.quantity) + "\n") _apply_font(p, sz_item, b_item)
p._raw(b'\x1b\x21\x00') _raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
p._raw(b'\x1b\x45\x00') _reset_font(p)
_font(p, sz_opt, bold_opt) opts = _parse_options(item)
# Quick options (* marker)
if opts["quick"]:
if compact:
parts = []
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
parts.append(f"{n} x{qty}" if qty > 1 else n)
_apply_font(p, sz_qk, b_qk)
_raw_text(p, "* " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
_apply_font(p, sz_qk, b_qk)
_raw_text(p, line + "\n")
_reset_font(p)
# Preferences (> marker)
if opts["pref"]:
if compact:
parts = []
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
parts.append(f"{n} · {s}" if s else n)
_apply_font(p, sz_pr, b_pr)
_raw_text(p, "> " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
line = f"> {n} · {s}" if s else f"> {n}"
_apply_font(p, sz_pr, b_pr)
_raw_text(p, line + "\n")
_reset_font(p)
# Extras (+ marker)
if opts["extra"]:
if compact:
parts = []
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
part = f"{n} · {s}" if s else n
if qty > 1:
part += f" · x{qty}"
parts.append(part)
_apply_font(p, sz_ex, b_ex)
_raw_text(p, "+ " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
line = f"+ {n}"
if s:
line += f" · {s}"
if qty > 1:
line += f" · x{qty}"
_apply_font(p, sz_ex, b_ex)
_raw_text(p, line + "\n")
_reset_font(p)
# Legacy untagged options
for entry in opts["unknown"]:
_apply_font(p, sz_ex, b_ex)
_raw_text(p, f"+ {entry}\n")
_reset_font(p)
# Removed ingredients (- marker)
if item.removed_ingredients: if item.removed_ingredients:
try: try:
removed_ids = json.loads(item.removed_ingredients) removed = json.loads(item.removed_ingredients)
if removed_ids: if removed:
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n") names = [n.upper() if c_ing else n for n in removed]
except (json.JSONDecodeError, TypeError): joined = " · ".join(names)
pass _apply_font(p, sz_ing, b_ing)
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
if item.selected_options: _reset_font(p)
try:
option_ids = json.loads(item.selected_options)
if option_ids:
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
# Per-item note
if item.notes: if item.notes:
_raw_text(p, f" (i) {item.notes}\n") note_text = item.notes.upper() if c_note else item.notes
_apply_font(p, sz_note, b_note)
if compact:
_raw_text(p, f"! {note_text}\n")
else:
_raw_text(p, f"\n(!) {note_text}\n\n")
_reset_font(p)
p._raw(b'\x1b\x21\x00') # Blank line between items in detailed mode
p._raw(b'\x1b\x45\x00') if not compact:
p._raw(b'\n')
_divider(p, div) _divider(p, div)
# Order-level notes
if order.notes: if order.notes:
p._raw(b'\x1b\x21\x30') note_text = order.notes.upper() if c_onote else order.notes
_raw_text(p, "Σημειωσεις:\n") _apply_font(p, sz_onote, b_onote)
p._raw(b'\x1b\x21\x10') _raw_text(p, f"Σημ: {note_text}\n")
_raw_text(p, f"{order.notes}\n") _reset_font(p)
p._raw(b'\x1b\x21\x00') if not compact:
_divider(p) _divider(p, div)
# Footer (detailed only)
if not compact:
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Τελος Παραγγελιας\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Τελος Παραγγελιας\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n') p._raw(b'\n\n\n')
p.cut() p.cut()

View File

@@ -0,0 +1,84 @@
"""
SSE Event Bus — in-memory broadcaster for Server-Sent Events.
All routers import `broadcast_sync()` to push events from sync routes.
The SSE endpoint imports `subscribe()` / `unsubscribe()` to manage per-client queues.
Event shape (JSON-serialisable dict):
{ "type": "<event_type>", "data": { ... } }
Supported event types:
order_updated — order created / item added / transferred / merged
order_paid — items paid on an order
order_closed — order closed or cancelled
table_list_changed — table added/removed
table_flags_changed — flags set/cleared on a table
message_sent — new staff message (targeted or broadcast)
shift_changed — shift started / ended by manager
business_day_changed — business day opened / closed
"""
import asyncio
import json
from typing import Dict, Set
# Captured once at startup by init_loop() called from lifespan.
# Sync route threads use this to schedule coroutines safely.
_main_loop: asyncio.AbstractEventLoop | None = None
# waiter_id → set of asyncio.Queue (one per SSE connection for that user)
_queues: Dict[int, Set[asyncio.Queue]] = {}
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Call once from the FastAPI lifespan (async context) to capture the event loop."""
global _main_loop
_main_loop = loop
async def subscribe(user_id: int) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=256)
if user_id not in _queues:
_queues[user_id] = set()
_queues[user_id].add(q)
return q
async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
if user_id in _queues:
_queues[user_id].discard(q)
if not _queues[user_id]:
del _queues[user_id]
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
"""
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
"""
if _main_loop is None:
return
_main_loop.call_soon_threadsafe(
_main_loop.create_task,
broadcast(event_type, data, user_ids=user_ids),
)
async def broadcast(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
"""
Push an event to connected clients.
user_ids=None → broadcast to ALL connected users
user_ids=[...] → send only to those specific user IDs
"""
payload = json.dumps({"type": event_type, "data": data})
targets = (
{uid: qs for uid, qs in _queues.items() if uid in user_ids}
if user_ids is not None
else dict(_queues)
)
for qs in targets.values():
for q in list(qs):
try:
q.put_nowait(payload)
except asyncio.QueueFull:
pass # slow client — drop rather than block

View File

@@ -49,6 +49,7 @@ const EVENT_LABELS = {
ORDER_OPENED: 'Άνοιγμα', ORDER_OPENED: 'Άνοιγμα',
ITEMS_ADDED: 'Προσθήκη', ITEMS_ADDED: 'Προσθήκη',
PAYMENT: 'Πληρωμή', PAYMENT: 'Πληρωμή',
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
ORDER_CLOSED: 'Κλείσιμο', ORDER_CLOSED: 'Κλείσιμο',
ORDER_CANCELLED: 'Ακύρωση', ORDER_CANCELLED: 'Ακύρωση',
ITEM_CANCELLED: 'Ακύρωση αντ.', ITEM_CANCELLED: 'Ακύρωση αντ.',
@@ -60,30 +61,47 @@ function AuditTab({ order, waiterMap }) {
} }
return ( return (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{order.audit_logs.map(log => ( {order.audit_logs.map(log => {
<div key={log.id} className="flex items-start gap-3 px-4 py-3"> const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
<div className="shrink-0 mt-0.5"> const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ const badgeClass = isDuplicate
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' : ? 'bg-red-100 text-red-700'
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' : : isPayment ? 'bg-green-100 text-green-700'
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' : : log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
'bg-blue-100 text-blue-700' : log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
}`}> : 'bg-blue-100 text-blue-700'
{EVENT_LABELS[log.event_type] ?? log.event_type} // Show offline_at (real payment time) when available, else server created_at
</span> const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
return (
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
<div className="shrink-0 mt-0.5">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
{EVENT_LABELS[log.event_type] ?? log.event_type}
</span>
{isDuplicate && (
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
)}
</div>
<div className="flex-1 min-w-0 text-sm text-gray-700">
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
{log.amount != null && (
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
{log.amount.toFixed(2)}
</span>
)}
{log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
)}
</div>
<div className="text-right shrink-0">
<span className="text-xs text-gray-400">{displayTime}</span>
{log.offline_at && (
<span className="block text-xs text-orange-400">offline</span>
)}
</div>
</div> </div>
<div className="flex-1 min-w-0 text-sm text-gray-700"> )
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span> })}
{log.amount != null && (
<span className="ml-2 font-semibold text-green-700">{log.amount.toFixed(2)}</span>
)}
{log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
)}
</div>
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
</div>
))}
</div> </div>
) )
} }

View File

@@ -270,7 +270,7 @@ function FlagDefsSection() {
const qc = useQueryClient() const qc = useQueryClient()
const [editingId, setEditingId] = useState(null) const [editingId, setEditingId] = useState(null)
const [editForm, setEditForm] = useState({}) const [editForm, setEditForm] = useState({})
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280' }) const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280', text_color: null })
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const { data: flags = [], isLoading } = useQuery({ const { data: flags = [], isLoading } = useQuery({
queryKey: ['flag-defs'], queryKey: ['flag-defs'],
@@ -279,7 +279,7 @@ function FlagDefsSection() {
}) })
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (body) => client.post('/api/flags/defs', body), mutationFn: (body) => client.post('/api/flags/defs', body),
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280' }) }, onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280', text_color: null }) },
onError: () => toast.error('Σφάλμα'), onError: () => toast.error('Σφάλμα'),
}) })
const updateMut = useMutation({ const updateMut = useMutation({
@@ -294,7 +294,7 @@ function FlagDefsSection() {
}) })
function startEdit(flag) { function startEdit(flag) {
setEditingId(flag.id) setEditingId(flag.id)
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', sort_order: flag.sort_order }) setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', text_color: flag.text_color || null, sort_order: flag.sort_order })
} }
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' } const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
return ( return (
@@ -320,6 +320,13 @@ function FlagDefsSection() {
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} /> style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
))}
</div>
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending} <button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button> style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button> <button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
@@ -342,6 +349,12 @@ function FlagDefsSection() {
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} /> style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
))} ))}
</div> </div>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
))}
</div>
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending} <button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}></button> style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}></button>
<button onClick={() => setEditingId(null)} <button onClick={() => setEditingId(null)}

View File

@@ -4,11 +4,9 @@ import toast from 'react-hot-toast'
import client from '../../../api/client' import client from '../../../api/client'
// ── Font option definitions ──────────────────────────────────────────────── // ── Font option definitions ────────────────────────────────────────────────
// Value encodes: "ESC_BANG_BYTE:BOLD" where BOLD is 0 or 1 // Value encodes: "SIZE:BOLD:CAPS"
// ESC ! correct bit map for TP850UE: // SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
// bit3 (0x08) = bold // BOLD: 0|1 CAPS: 0|1
// bit4 (0x10) = double-height
// bit5 (0x20) = double-width
const FONT_SIZE_OPTIONS = [ const FONT_SIZE_OPTIONS = [
{ size: '0', label: 'Μικρά' }, { size: '0', label: 'Μικρά' },
{ size: '16', label: 'Ψηλά' }, { size: '16', label: 'Ψηλά' },
@@ -16,12 +14,13 @@ const FONT_SIZE_OPTIONS = [
{ size: '48', label: 'Ψηλά και Πλατιά' }, { size: '48', label: 'Ψηλά και Πλατιά' },
] ]
// We store the value as "SIZE:BOLD" e.g. "16:1" or "0:0" function encodeFont(size, bold, caps) {
function encodeFont(size, bold) { return `${size}:${bold ? '1' : '0'}` } return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
}
function decodeFont(val) { function decodeFont(val) {
if (!val) return { size: '0', bold: false } if (!val) return { size: '0', bold: false, caps: false }
const [size, bold] = val.split(':') const [size, bold, caps] = val.split(':')
return { size: size ?? '0', bold: bold === '1' } return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
} }
const DIVIDER_OPTIONS = [ const DIVIDER_OPTIONS = [
@@ -31,26 +30,21 @@ const DIVIDER_OPTIONS = [
{ value: 'empty', label: 'Κενή γραμμή', chars: '' }, { value: 'empty', label: 'Κενή γραμμή', chars: '' },
] ]
const FONT_FIELDS = [
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Κάθε πιάτο/ποτό στο ticket κουζίνας' },
{ key: 'print.font_options', label: 'Επιλογές / Τροποποιητές', sub: 'Extras, αφαιρέσεις, σημειώσεις' },
{ key: 'print.font_table', label: 'Τραπέζι & Σερβιτόρος', sub: 'Αριθμός τραπεζιού, όνομα σερβιτόρου, ώρα' },
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: 'Η επικεφαλίδα "Παραγγελία #Χ"' },
{ key: 'print.font_header', label: 'Κεφαλίδα / Τίτλος', sub: 'Τίτλοι ενοτήτων, κεφαλίδες αναφορών' },
]
const FONT_DEFAULTS = { const FONT_DEFAULTS = {
'print.font_item_name': '16:0', 'print.font_order_number': '48:1:0',
'print.font_options': '0:0', 'print.font_meta': '0:0:0',
'print.font_table': '16:0', 'print.font_item_name': '16:1:0',
'print.font_order_number': '48:1', 'print.font_quick': '0:0:0',
'print.font_header': '48:1', 'print.font_pref': '0:0:0',
'print.font_extra': '0:0:0',
'print.font_ingredient': '0:0:0',
'print.font_item_note': '0:0:0',
'print.font_order_note': '0:1:0',
'print.divider_style': 'dash', 'print.divider_style': 'dash',
'print.ticket_mode': 'detailed',
} }
// ── Preview box ──────────────────────────────────────────────────────────── // ── Preview ────────────────────────────────────────────────────────────────
// Fixed height tall enough for the largest option (Ψηλά και Πλατιά).
// All rows share the same height so columns stay aligned.
const PREVIEW_W = 200 const PREVIEW_W = 200
const PREVIEW_H = 50 const PREVIEW_H = 50
@@ -61,7 +55,7 @@ const sizeStyle = {
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 }, '48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
} }
function FontPreview({ size, bold }) { function FontPreview({ size, bold, caps }) {
const s = sizeStyle[size] ?? sizeStyle['0'] const s = sizeStyle[size] ?? sizeStyle['0']
return ( return (
<div style={{ <div style={{
@@ -80,30 +74,66 @@ function FontPreview({ size, bold }) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
display: 'block', display: 'block',
}}> }}>
SAMPLE {caps ? 'SAMPLE' : 'Sample'}
</span> </span>
</div> </div>
) )
} }
// ── Single font row ──────────────────────────────────────────────────────── // ── Toggle button (shared) ─────────────────────────────────────────────────
function FontRow({ field, value, onChange, isPending }) { function ToggleBtn({ active, onClick, disabled, label }) {
const { size, bold } = decodeFont(value) return (
<button
onClick={onClick}
disabled={disabled}
style={{
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
border: `1.5px solid ${active ? '#3758c9' : '#dfe2e6'}`,
background: active ? '#eff3ff' : 'white',
color: active ? '#3758c9' : '#6b7280',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: `2px solid ${active ? '#3758c9' : '#9ca3af'}`,
background: active ? '#3758c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}></span>}
</span>
{label}
</button>
)
}
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold)) } // ── Single font row ────────────────────────────────────────────────────────
function handleBold() { onChange(field.key, encodeFont(size, !bold)) } function FontRow({ field, value, onChange, isPending, nested = false }) {
const { size, bold, caps } = decodeFont(value)
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) }
function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) }
function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) }
return ( return (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 14, display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 20px', borderBottom: '1px solid #f4f4f2', padding: nested ? '10px 20px 10px 36px' : '14px 20px',
borderBottom: '1px solid #f4f4f2',
background: nested ? '#fafafa' : 'white',
}}> }}>
{nested && (
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}></span>
)}
{/* Label */} {/* Label */}
<div style={{ flex: '1 1 160px', minWidth: 140 }}> <div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}> <span style={{ fontSize: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
{field.label} {field.label}
</span> </span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span> {field.sub && (
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
)}
</div> </div>
{/* Size dropdown */} {/* Size dropdown */}
@@ -123,31 +153,28 @@ function FontRow({ field, value, onChange, isPending }) {
</select> </select>
{/* Bold toggle */} {/* Bold toggle */}
<button <ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
onClick={handleBold}
disabled={isPending} {/* Caps toggle */}
style={{ <ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
border: `1.5px solid ${bold ? '#3758c9' : '#dfe2e6'}`,
background: bold ? '#eff3ff' : 'white',
color: bold ? '#3758c9' : '#6b7280',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: `2px solid ${bold ? '#3758c9' : '#9ca3af'}`,
background: bold ? '#3758c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{bold && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}></span>}
</span>
ΕΝΤΟΝΑ
</button>
{/* Preview */} {/* Preview */}
<FontPreview size={size} bold={bold} /> <FontPreview size={size} bold={bold} caps={caps} />
</div>
)
}
// ── Subgroup header row ────────────────────────────────────────────────────
function SubgroupHeader({ label }) {
return (
<div style={{
padding: '8px 20px 6px',
borderBottom: '1px solid #f4f4f2',
background: '#f9fafb',
}}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
{label}
</span>
</div> </div>
) )
} }
@@ -181,10 +208,10 @@ function DividerRow({ value, onChange, isPending }) {
))} ))}
</select> </select>
{/* spacer to align with bold button column */} {/* spacer to align with bold+caps column */}
<div style={{ width: 87, flexShrink: 0 }} /> <div style={{ width: 194, flexShrink: 0 }} />
{/* Preview — same fixed size as font previews */} {/* Preview */}
<div style={{ <div style={{
background: '#1a1a1a', borderRadius: 8, background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0, width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
@@ -202,10 +229,127 @@ function DividerRow({ value, onChange, isPending }) {
) )
} }
// ── Ticket mode section ────────────────────────────────────────────────────
function TicketModeSection({ value, onChange, isPending, printers }) {
const [selectedPrinter, setSelectedPrinter] = useState(null)
const [printing, setPrinting] = useState(false)
// Auto-select first active printer
useEffect(() => {
if (printers.length > 0 && !selectedPrinter) {
const first = printers.find(p => p.is_active) ?? printers[0]
setSelectedPrinter(first.id)
}
}, [printers])
async function handleTestOrder() {
if (!selectedPrinter) return
setPrinting(true)
try {
const res = await client.post(`/api/system/printers/test-order?printer_id=${selectedPrinter}`)
if (res.data.success) toast.success('Test order στάλθηκε!')
else toast.error(`Σφάλμα: ${res.data.error}`)
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setPrinting(false)
}
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Τύπος Εκτύπωσης</h2>
<p className="text-xs text-gray-400 mt-0.5">
Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.
</p>
</div>
<div style={{ display: 'flex', gap: 12, padding: '16px 20px', flexWrap: 'wrap' }}>
{[
{
key: 'detailed',
title: 'Αναλυτικό',
desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.',
},
{
key: 'compact',
title: 'Συμπαγές',
desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.',
},
].map(opt => {
const active = value === opt.key
return (
<button
key={opt.key}
onClick={() => onChange('print.ticket_mode', opt.key)}
disabled={isPending}
style={{
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
borderRadius: 10, cursor: 'pointer',
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
background: active ? '#eff3ff' : 'white',
}}
>
<div style={{ fontSize: 14, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 4 }}>
{opt.title}
</div>
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>{opt.desc}</div>
</button>
)
})}
{/* Test order button */}
<button
onClick={handleTestOrder}
disabled={printing || !selectedPrinter}
style={{
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
borderRadius: 10, cursor: printing || !selectedPrinter ? 'default' : 'pointer',
border: '2px solid #e5e7eb',
background: printing ? '#f9fafb' : 'white',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: printing ? '#9ca3af' : '#111315', marginBottom: 4 }}>
{printing ? 'Εκτύπωση…' : 'Δοκιμαστική Εκτύπωση'}
</div>
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Εκτυπώνει fake παραγγελία με όλους τους τύπους επιλογών για προεπισκόπηση ρυθμίσεων.
</div>
</div>
{printers.length > 0 && (
<div style={{ marginTop: 10 }} onClick={e => e.stopPropagation()}>
<select
value={selectedPrinter ?? ''}
onChange={e => setSelectedPrinter(Number(e.target.value))}
disabled={printing}
style={{
width: '100%', height: 32, borderRadius: 6,
border: '1px solid #dfe2e6', background: 'white',
padding: '0 8px', fontSize: 12, color: '#374151', cursor: 'pointer',
}}
>
{printers.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
))}
</select>
</div>
)}
{printers.length === 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444' }}>
Δεν υπάρχουν εκτυπωτές
</div>
)}
</button>
</div>
</div>
)
}
// ── Printers section ─────────────────────────────────────────────────────── // ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }] const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true } const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) { function PrinterForm({ initial, onSave, onCancel, isPending }) {
@@ -284,7 +428,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
opacity: printer.is_active ? 1 : 0.5, opacity: printer.is_active ? 1 : 0.5,
flexWrap: 'wrap', flexWrap: 'wrap',
}}> }}>
{/* Enable/disable toggle */}
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'} <button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
style={{ style={{
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0, width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
@@ -297,7 +440,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
}} /> }} />
</button> </button>
{/* Name + connection info */}
<div style={{ flex: 1, minWidth: 120 }}> <div style={{ flex: 1, minWidth: 120 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span> <span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}> <span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
@@ -306,7 +448,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span> <span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
</div> </div>
{/* Reachability badge */}
<span style={{ <span style={{
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0, fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2', background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
@@ -315,7 +456,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'} {reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
</span> </span>
{/* Actions */}
<button onClick={() => onTest(printer.id)} disabled={testPending} <button onClick={() => onTest(printer.id)} disabled={testPending}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}> style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Test Print Test Print
@@ -422,6 +562,39 @@ function PrintersSection() {
) )
} }
// ── Font groups definition ─────────────────────────────────────────────────
const FONT_GROUPS = [
{
group: 'Αριθμός Παραγγελίας',
fields: [
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: '"Παραγγελια #42" — η επικεφαλίδα του ticket' },
],
},
{
group: 'Επικεφαλίδα Ticket',
fields: [
{ key: 'print.font_meta', label: 'Τραπέζι · Σερβιτόρος · Ώρα', sub: 'Γραμμές ταυτότητας κάτω από τον αριθμό' },
],
},
{
group: 'Αντικείμενα',
fields: [
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Το κυρίως πιάτο/ποτό — γραμμή dot-leader' },
{ key: 'print.font_quick', label: '* Quick Options', sub: 'Γρήγορες επιλογές ( * )' },
{ key: 'print.font_pref', label: '> Προτιμήσεις', sub: 'Επιλογές preference sets ( > )' },
{ key: 'print.font_extra', label: '+ Extras', sub: 'Πρόσθετα / τροποποιητές ( + )' },
{ key: 'print.font_ingredient', label: '- Αφαιρέσεις', sub: 'ΧΩΡΙΣ: συστατικά ( - )' },
],
},
{
group: 'Σημειώσεις',
fields: [
{ key: 'print.font_item_note', label: '(!) Σημείωση Αντικειμένου', sub: 'Free-text σημείωση ανά πιάτο' },
{ key: 'print.font_order_note', label: 'Σημειώσεις Παραγγελίας', sub: 'Η γενική σημείωση της παραγγελίας' },
],
},
]
// ── Main tab ─────────────────────────────────────────────────────────────── // ── Main tab ───────────────────────────────────────────────────────────────
export default function PrintFontsTab() { export default function PrintFontsTab() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -432,6 +605,12 @@ export default function PrintFontsTab() {
staleTime: 30_000, staleTime: 30_000,
}) })
const { data: printers = [] } = useQuery({
queryKey: ['printers-all'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 15_000,
})
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }), mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) }, onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
@@ -448,28 +627,44 @@ export default function PrintFontsTab() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 1. Printers */}
<PrintersSection /> <PrintersSection />
{/* Font sizes card */} {/* 2. Ticket mode */}
<TicketModeSection
value={val('print.ticket_mode')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 3. Font sizes — grouped */}
<div className="card divide-y divide-gray-100"> <div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2> <h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
<p className="text-xs text-gray-400 mt-0.5"> <p className="text-xs text-gray-400 mt-0.5">
Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση. Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
</p> </p>
</div> </div>
{FONT_FIELDS.map(field => (
<FontRow {FONT_GROUPS.map(group => (
key={field.key} <div key={group.group}>
field={field} <SubgroupHeader label={group.group} />
value={val(field.key)} {group.fields.map((field, idx) => (
onChange={handleChange} <FontRow
isPending={updateMut.isPending} key={field.key}
/> field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
))}
</div>
))} ))}
</div> </div>
{/* Divider style card */} {/* 4. Divider style */}
<div className="card divide-y divide-gray-100"> <div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2> <h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.8icf0qrbd5" "revision": "0.jqv9du572qo"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.15.1", "axios": "^1.15.1",
"dexie": "^4.4.2",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
@@ -2940,6 +2941,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dexie": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.15.1", "axios": "^1.15.1",
"dexie": "^4.4.2",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",

View File

@@ -4,13 +4,17 @@ import useAuthStore from './store/authStore'
import useShiftStore from './store/shiftStore' import useShiftStore from './store/shiftStore'
import useThemeStore from './store/themeStore' import useThemeStore from './store/themeStore'
import useTableColourStore from './store/tableColourStore' import useTableColourStore from './store/tableColourStore'
import useConnectionStore from './store/connectionStore'
import client from './api/client' import client from './api/client'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage' import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage' import TableDetailPage from './pages/TableDetailPage'
import AddItemsPage from './pages/AddItemsPage' import AddItemsPage from './pages/AddItemsPage'
import OfflinePage from './pages/OfflinePage' import OfflinePage from './pages/OfflinePage'
import SettingsPage from './pages/SettingsPage'
import { NotificationProvider } from './context/NotificationContext' import { NotificationProvider } from './context/NotificationContext'
import { SSEProvider } from './context/SSEContext'
import ConnectionLostModal from './components/ConnectionLostModal'
// ─── Utility ───────────────────────────────────────────────────────────────── // ─── Utility ─────────────────────────────────────────────────────────────────
@@ -269,11 +273,18 @@ function AuthRehydrator() {
function OfflineListener() { function OfflineListener() {
const navigate = useNavigate() const navigate = useNavigate()
const { token } = useAuthStore()
const { status } = useConnectionStore()
useEffect(() => { useEffect(() => {
const handler = () => navigate('/offline') function handler() {
// If user is logged in, ConnectionLostModal handles it — don't redirect to /offline
if (token && status !== 'online') return
// Not logged in and server is down → redirect to offline page
if (!token) navigate('/offline')
}
window.addEventListener('backend-offline', handler) window.addEventListener('backend-offline', handler)
return () => window.removeEventListener('backend-offline', handler) return () => window.removeEventListener('backend-offline', handler)
}, [navigate]) }, [navigate, token, status])
return null return null
} }
@@ -307,18 +318,22 @@ export default function App() {
<ColourLoader /> <ColourLoader />
<AuthRehydrator /> <AuthRehydrator />
<OfflineListener /> <OfflineListener />
<NotificationProvider> <SSEProvider>
<Routes> <NotificationProvider>
<Route path="/login" element={<LoginPage />} /> <ConnectionLostModal />
<Route path="/offline" element={<OfflinePage />} /> <Routes>
<Route element={<AppLayout />}> <Route path="/login" element={<LoginPage />} />
<Route path="/tables" element={<TableListPage />} /> <Route path="/offline" element={<OfflinePage />} />
<Route path="/tables/:tableId" element={<TableDetailPage />} /> <Route element={<AppLayout />}>
<Route path="/tables/:tableId/add" element={<AddItemsPage />} /> <Route path="/tables" element={<TableListPage />} />
</Route> <Route path="/tables/:tableId" element={<TableDetailPage />} />
<Route path="*" element={<Navigate to="/tables" replace />} /> <Route path="/tables/:tableId/add" element={<AddItemsPage />} />
</Routes> <Route path="/settings" element={<SettingsPage />} />
</NotificationProvider> </Route>
<Route path="*" element={<Navigate to="/tables" replace />} />
</Routes>
</NotificationProvider>
</SSEProvider>
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
import client from '../api/client'
import { useSSEContext } from '../context/SSEContext'
const RETRY_INTERVAL = 10_000 // 10s auto-retry while modal is open in Wait mode
export default function ConnectionLostModal() {
const { status, setOnline, enterEmergency } = useConnectionStore()
const { reconnect, fullRefresh } = useSSEContext()
const [retrying, setRetrying] = useState(false)
const retryRef = useRef(null)
const isVisible = status === 'lost'
async function tryReconnect() {
setRetrying(true)
try {
await client.get('/api/system/health')
// Server is back
setOnline()
reconnect()
await fullRefresh()
} catch {
// Still down — stay in modal
} finally {
setRetrying(false)
}
}
// Auto-retry every 10s while modal is open
useEffect(() => {
if (!isVisible) {
clearInterval(retryRef.current)
return
}
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
return () => clearInterval(retryRef.current)
}, [isVisible])
if (!isVisible) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 99999,
background: 'rgba(0,0,0,0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}>
<div style={{
background: '#1e293b',
border: '2px solid #ef4444',
borderRadius: 20,
padding: '32px 28px',
maxWidth: 400, width: '100%',
textAlign: 'center',
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<p style={{
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
marginBottom: 10,
}}>
Χάθηκε η σύνδεση με τον Manager
</p>
<p style={{
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
marginBottom: 28,
}}>
Δεν μπορώ να φτάσω στον server.{'\n'}
Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'}
για να συνεχίσεις με τοπικά δεδομένα.
</p>
<div style={{
display: 'flex', gap: 12, justifyContent: 'center',
}}>
<button
onClick={enterEmergency}
style={{
flex: 1,
height: 48, borderRadius: 12, border: 'none',
background: '#dc2626', color: '#fff',
fontSize: 15, fontWeight: 700,
cursor: 'pointer',
}}
>
EMERGENCY MODE
</button>
</div>
<p style={{ fontSize: 11, color: '#475569', marginTop: 16 }}>
Αυτόματη επανάληψη κάθε 10 δευτερόλεπτα
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import useConnectionStore from '../store/connectionStore'
export default function EmergencyBar() {
const { status, lostAt } = useConnectionStore()
const [elapsed, setElapsed] = useState('')
useEffect(() => {
if (status !== 'emergency' || !lostAt) return
function tick() {
const secs = Math.floor((Date.now() - lostAt.getTime()) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
setElapsed(`${m}:${String(s).padStart(2, '0')}`)
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [status, lostAt])
if (status !== 'emergency') return null
return (
<div style={{
background: '#dc2626',
color: '#fef08a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 8,
padding: '8px 16px',
fontSize: 13, fontWeight: 700,
letterSpacing: 0.5,
userSelect: 'none',
}}>
<span>EMERGENCY MODE</span>
{elapsed && (
<span style={{ opacity: 0.85, fontWeight: 400 }}>({elapsed})</span>
)}
</div>
)
}

View File

@@ -147,20 +147,20 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
const prefChoices = preferenceSets.flatMap(ps => { const prefChoices = preferenceSets.flatMap(ps => {
const choice = selectedPreferences[ps.id] const choice = selectedPreferences[ps.id]
if (!choice) return [] if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }] const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 }) if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) { if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = selectedSharedSubs[ps.id] ?? null const sharedSub = selectedSharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 }) if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
} }
return entries return entries
}) })
const optionEntries = selectedOptions.flatMap(o => { const optionEntries = selectedOptions.flatMap(o => {
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }] const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0, type: 'extra' }]
const sub = selectedOptionSubs[o.id] const sub = selectedOptionSubs[o.id]
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 }) if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
return entries return entries
}) })

View File

@@ -715,9 +715,9 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name) && (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) return [] if (isFullyDefault) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }] const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 }) if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 }) if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
return entries return entries
}) })
@@ -727,8 +727,8 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const sub = opt.sub_choices?.find(s => s.name === sel.subName) const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const entries = [] const entries = []
for (let i = 0; i < sel.qty; i++) { for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }) entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 }) if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
} }
return entries return entries
}) })
@@ -736,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const quickEntries = quickOptions.flatMap(opt => { const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0 const q = quickState[opt.id] || 0
if (q === 0) return [] if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 })) return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
}) })
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name) const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)

View File

@@ -73,12 +73,11 @@ function buildSections(parent, subcategories, directProducts) {
return sections.sort((a, b) => a.sort_order - b.sort_order) return sections.sort((a, b) => a.sort_order - b.sort_order)
} }
export default function ProductPicker({ categories, products, onAdd }) { export default function ProductPicker({ categories, products, onAdd, viewAllOpen, setViewAllOpen }) {
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order) const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const initialCatId = topLevel[0]?.id ?? null const initialCatId = topLevel[0]?.id ?? null
const [activeCat, setActiveCat] = useState(initialCatId) const [activeCat, setActiveCat] = useState(initialCatId)
const [drawerProduct, setDrawerProduct] = useState(null) const [drawerProduct, setDrawerProduct] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
// Track which sub-category sections are expanded (by sub-cat id or '__general__') // Track which sub-category sections are expanded (by sub-cat id or '__general__')
const [expandedSubs, setExpandedSubs] = useState(() => { const [expandedSubs, setExpandedSubs] = useState(() => {
if (!initialCatId) return {} if (!initialCatId) return {}
@@ -125,18 +124,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
return ( return (
<div className="product-picker"> <div className="product-picker">
<div className="category-tabs"> <div className="category-tabs">
<div className="category-tabs__sticky">
<button
className="cat-tab cat-tab--viewall"
onClick={() => setViewAllOpen(true)}
title="Εμφάνιση όλων"
>
<CategoriesIcon width="20" height="20" />
</button>
</div>
<div className="category-tabs__scroll-wrap"> <div className="category-tabs__scroll-wrap">
<div className="category-tabs__fade" />
<div className="category-tabs__scroll"> <div className="category-tabs__scroll">
{topLevel.map(cat => { {topLevel.map(cat => {
const isActive = activeCat === cat.id const isActive = activeCat === cat.id

View File

@@ -2,6 +2,8 @@ import { useRef, useState } from 'react'
import useThemeStore from '../store/themeStore' import useThemeStore from '../store/themeStore'
import useTableColourStore from '../store/tableColourStore' import useTableColourStore from '../store/tableColourStore'
const API_URL = import.meta.env.VITE_API_URL || ''
const STATUS_LABELS = { const STATUS_LABELS = {
free: 'ΕΛΕΥΘΕΡΟ', free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ', open: 'ΑΝΟΙΧΤΟ',
@@ -13,7 +15,555 @@ const STATUS_LABELS = {
const DRAG_THRESHOLD = 8 const DRAG_THRESHOLD = 8
const HOLD_MS = 480 const HOLD_MS = 480
export default function TableCard({ table, order, isMine, flags = [], groupName = '', onClick, onLongPress }) { // ─── Avatar helpers ───────────────────────────────────────────────────────────
const AVATAR_PALETTE = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a']
function avatarColor(name = '') {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
}
function WaiterAvatar({ waiter, size = 22, ring }) {
const displayName = waiter.nickname || waiter.full_name || waiter.username || '?'
const initials = displayName.trim().split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
const ringStyle = ring ? { boxShadow: `0 0 0 2px ${ring}` } : {}
if (waiter.avatar_url) {
return (
<img
src={API_URL + waiter.avatar_url}
alt={displayName}
style={{
width: size, height: size, borderRadius: '50%',
objectFit: 'cover', flexShrink: 0,
...ringStyle,
}}
/>
)
}
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(displayName),
color: 'white', fontSize: size * 0.4, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
...ringStyle,
}}>{initials}</div>
)
}
// Renders [icon] Name, [icon] Name inline. Falls back to icons + "X Waiters" if they don't fit
// (we approximate "don't fit" as > 2 waiters for the compact footer height).
function WaiterRow({ waiters, size = 22, cfg }) {
if (!waiters?.length) return null
const textColor = cfg.nameText
// ≤ 2 waiters: show icon + name pairs
if (waiters.length <= 2) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'nowrap', overflow: 'hidden', minWidth: 0 }}>
{waiters.map((w, i) => {
const name = w.nickname || w.full_name || w.username || '?'
return (
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, overflow: 'hidden' }}>
{i > 0 && <span style={{ color: textColor, opacity: 0.3, fontSize: 14, flexShrink: 0 }}>·</span>}
<WaiterAvatar waiter={w} size={size} />
<span style={{
fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.85,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{name}</span>
</div>
)
})}
</div>
)
}
// > 2 waiters: icons only + "X Waiters" label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={w.id} style={{ marginLeft: i === 0 ? 0 : -(size * 0.28) }}>
<WaiterAvatar waiter={w} size={size} ring={cfg.cardBg} />
</div>
))}
{waiters.length > 3 && (
<div style={{
marginLeft: -(size * 0.28), height: size, padding: '0 6px',
borderRadius: size, background: `${cfg.nameText}20`,
color: cfg.nameText, fontSize: 10, fontWeight: 700,
display: 'flex', alignItems: 'center',
}}>+{waiters.length - 3}</div>
)}
<span style={{ fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.7, marginLeft: 4 }}>
{waiters.length} σερβιτόροι
</span>
</div>
)
}
// ─── Status pill ──────────────────────────────────────────────────────────────
function StatusPill({ label, badgeBg, badgeText, small }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
height: small ? 18 : 20,
padding: small ? '0 6px' : '0 8px',
borderRadius: 4,
background: badgeBg,
color: badgeText,
fontSize: small ? 9 : 10,
fontWeight: 800,
letterSpacing: 0.4,
whiteSpace: 'nowrap',
}}>{label}</span>
)
}
// ─── Flag dot ─────────────────────────────────────────────────────────────────
function FlagDot({ flag, size = 22 }) {
const textColor = flag.text_color || '#ffffff'
return (
<div
title={flag.name}
style={{
width: size, height: size, borderRadius: '50%',
background: flag.color || '#6295F3',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.55,
flexShrink: 0,
color: textColor,
}}
>
{flag.emoji || '🏷️'}
</div>
)
}
// ─── Flag overflow row: show up to maxShow dots, then +N bubble ───────────────
function FlagDots({ flags, size, maxShow }) {
if (!flags.length) return null
const visible = flags.slice(0, maxShow)
const overflow = flags.length - maxShow
return (
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
{visible.map(f => <FlagDot key={f.id} flag={f} size={size} />)}
{overflow > 0 && (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'rgba(0,0,0,0.18)',
color: '#fff', fontSize: size * 0.44, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>+{overflow}</div>
)}
</div>
)
}
// ─── Flag chip (icon + label) ─────────────────────────────────────────────────
function FlagChip({ flag }) {
const textColor = flag.text_color || '#ffffff'
return (
<div
title={flag.name}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px',
borderRadius: 13,
background: flag.color || '#6295F3',
flexShrink: 0,
}}
>
<span style={{ fontSize: 13, lineHeight: 1 }}>{flag.emoji || '🏷️'}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: textColor, whiteSpace: 'nowrap' }}>
{flag.name}
</span>
</div>
)
}
// ─── Amount display ───────────────────────────────────────────────────────────
function Amount({ value, size = 22, color }) {
const s = Number(value || 0).toFixed(2)
const [whole, cents] = s.split('.')
const isNum = typeof size === 'number'
const centsSize = isNum ? size * 0.56 : `calc(${size} * 0.56)`
return (
<div style={{ lineHeight: 1, color: color || 'inherit' }}>
<span style={{ fontSize: size, fontWeight: 800, letterSpacing: -0.5 }}>{whole}</span>
<span style={{ fontSize: centsSize, fontWeight: 800, opacity: 0.8 }}>.{cents}</span>
</div>
)
}
// ─── Card variants ────────────────────────────────────────────────────────────
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, status bottom.
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
return (
<div style={{
width: '100%', aspectRatio: '1 / 1.05',
background: cfg.cardBg, borderRadius: 14,
position: 'relative', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
padding: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
{/* top strip: badges up to 2, then +N */}
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<FlagDots flags={flags} size={16} maxShow={2} />
</div>
{/* center: name */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
}}>
{table.label || `T${table.number}`}
</div>
{/* bottom strip: status */}
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
<span style={{
fontSize: 7, fontWeight: 800, letterSpacing: 0.3,
color: cfg.badgeText, textTransform: 'uppercase',
background: cfg.badgeBg, borderRadius: 3,
padding: '1px 4px', whiteSpace: 'nowrap',
}}>
{STATUS_LABELS[statusKey]}
</span>
</div>
</div>
)
}
// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right.
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
return (
<div style={{
width: '100%', height: 64,
background: cfg.cardBg, borderRadius: 14,
padding: '10px 12px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 10, overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
<div style={{
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
}}>
{table.label || `T${table.number}`}
</div>
<div style={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
}}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
{flags.length > 0 && (
<FlagDots flags={flags} size={18} maxShow={3} />
)}
</div>
</div>
)
}
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return (
<div style={{
width: '100%', minHeight: 116,
background: cfg.cardBg, borderRadius: 16,
padding: '12px 12px 12px',
display: 'flex', gap: 8, overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
}}>
{/* left column */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
}}>
{table.label || `T${table.number}`}
</span>
<div style={{ marginTop: 5 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
</div>
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
</div>
</div>
{/* right column: flags — show 2, then +N */}
{flags.length > 0 && (
<div style={{
display: 'flex', flexDirection: 'column-reverse',
gap: 4, alignItems: 'flex-end', justifyContent: 'flex-start',
}}>
<FlagDots flags={flags} size={26} maxShow={2} />
</div>
)}
</div>
)
}
// 4x1 — full width horizontal. Name + amount left-center, badges (up to 3 + +N) + status right.
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
return (
<div style={{
width: '100%', height: 68,
background: cfg.cardBg, borderRadius: 14,
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>
{/* name */}
<div style={{
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
}}>
{table.label || `T${table.number}`}
</div>
{/* separator dot */}
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
{/* amount */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
</div>
{/* flags up to 3 + +N */}
{flags.length > 0 && (
<FlagDots flags={flags} size={24} maxShow={3} />
)}
{/* status */}
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
</div>
)
}
// 4x2 — full width, tall. One main row: name+zone left, status center, amount+flags right. Flag chips below. Waiter footer.
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
const isFree = !order
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
const showAmount = !isFree
const showWaiters = !isFree && waiterObjects.length > 0
return (
<div style={{
width: '100%',
background: cfg.cardBg, borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
display: 'flex', flexDirection: 'column',
}}>
{/* main body */}
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{/* left: name + zone */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: 800, fontSize: 'clamp(30px, 7vw, 44px)',
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
}}>
{table.label || `T${table.number}`}
</div>
{groupName && (
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
color: cfg.nameText, opacity: 0.6,
textTransform: 'uppercase', marginTop: 3,
}}>
{groupName}
</div>
)}
</div>
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
<div style={{ paddingTop: 4, flexShrink: 0 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
</div>
{/* right: amount — top-aligned */}
{showAmount && (
<div style={{ flexShrink: 0 }}>
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
</div>
)}
</div>
{/* flag chips row — right-aligned */}
{flags.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 6 }}>
{flags.slice(0, 4).map(f => <FlagChip key={f.id} flag={f} />)}
{flags.length > 4 && (
<div style={{
height: 26, padding: '0 9px', borderRadius: 13,
background: 'rgba(0,0,0,0.18)', color: '#fff',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center',
}}>+{flags.length - 4}</div>
)}
</div>
)}
</div>
{/* footer: waiters */}
<div style={{
borderTop: `1px solid ${cfg.nameText}22`,
padding: '10px 14px', minHeight: 40,
display: 'flex', alignItems: 'center',
}}>
{showWaiters
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}></span>
}
</div>
</div>
)
}
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: order items list. Footer: waiters.
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
const isFree = !order
const activeItems = order?.items?.filter(i => i.status === 'active') ?? []
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const showWaiters = !isFree && waiterObjects.length > 0
return (
<div style={{
width: '100%',
background: cfg.cardBg, borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
display: 'flex', flexDirection: 'column',
}}>
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
{/* left column: name, zone, amount, status, flags */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
<div>
<div style={{
fontWeight: 800, fontSize: 'clamp(28px, 6vw, 40px)',
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
}}>
{table.label || `T${table.number}`}
</div>
{groupName && (
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
color: cfg.nameText, opacity: 0.6,
textTransform: 'uppercase', marginTop: 3,
}}>
{groupName}
</div>
)}
</div>
<div style={{ marginTop: 10 }}>
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
</div>
<div style={{ marginTop: 8 }}>
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
</div>
{flags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<FlagDots flags={flags} size={22} maxShow={3} />
</div>
)}
</div>
{/* divider */}
<div style={{ width: 1, background: `${cfg.nameText}20`, alignSelf: 'stretch', flexShrink: 0 }} />
{/* right column: order items */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
{isFree ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Ελεύθερο</span>
</div>
) : activeItems.length === 0 ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Κανένα είδος</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
{activeItems.slice(0, 7).map(item => (
<div key={item.id} style={{ display: 'flex', alignItems: 'baseline', gap: 5, overflow: 'hidden', minWidth: 0 }}>
<span style={{
fontSize: 11, fontWeight: 700, color: cfg.nameText,
background: `${cfg.nameText}18`, borderRadius: 3,
padding: '1px 5px', flexShrink: 0,
}}>{item.quantity}×</span>
<span style={{
fontSize: 12, fontWeight: 500, color: cfg.nameText,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
}}>{item.product?.name || `#${item.product_id}`}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: cfg.nameText, opacity: 0.7, flexShrink: 0 }}>
{(item.unit_price * item.quantity).toFixed(2)}
</span>
</div>
))}
{activeItems.length > 7 && (
<div style={{ fontSize: 11, color: cfg.nameText, opacity: 0.5, marginTop: 2 }}>
+{activeItems.length - 7} ακόμα
</div>
)}
</div>
)}
</div>
</div>
{/* footer: waiters */}
<div style={{
borderTop: `1px solid ${cfg.nameText}22`,
padding: '10px 14px', minHeight: 38,
display: 'flex', alignItems: 'center',
}}>
{showWaiters
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}></span>
}
</div>
</div>
)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export default function TableCard({
table,
order,
isMine,
flags = [],
groupName = '',
waiterObjects = [],
density = '2x2',
onClick,
onLongPress,
}) {
const holdTimer = useRef(null) const holdTimer = useRef(null)
const startPos = useRef({ x: 0, y: 0 }) const startPos = useRef({ x: 0, y: 0 })
const didFire = useRef(false) const didFire = useRef(false)
@@ -31,8 +581,6 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
const mode = dark ? 'dark' : 'light' const mode = dark ? 'dark' : 'light'
const cfg = colours[mode][statusKey] const cfg = colours[mode][statusKey]
const displayName = table.label || `T${table.number}`
function cancel() { function cancel() {
clearTimeout(holdTimer.current) clearTimeout(holdTimer.current)
holdTimer.current = null holdTimer.current = null
@@ -57,10 +605,7 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel() if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
} }
function onTouchEnd() { function onTouchEnd() { cancel(); setShowTip(false) }
cancel()
setShowTip(false)
}
function onMouseDown(e) { function onMouseDown(e) {
startPos.current = { x: e.clientX, y: e.clientY } startPos.current = { x: e.clientX, y: e.clientY }
@@ -85,11 +630,21 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
onClick?.() onClick?.()
} }
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
const CardComponent = {
'1x1': Card1x1,
'2x1': Card2x1,
'2x2': Card2x2,
'4x1': Card4x1,
'4x2': Card4x2,
'4x3': Card4x3,
}[density] || Card2x2
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
<button <button
className="table-card-v2" style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
style={{ background: cfg.cardBg }}
onClick={handleClick} onClick={handleClick}
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
@@ -99,89 +654,16 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
{/* Top-left: table name + area */} <CardComponent {...cardProps} />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '65%' }}>
<span style={{
fontSize: 'clamp(22px, 5.5vw, 36px)',
fontWeight: 800,
lineHeight: 1.05,
color: cfg.nameText,
letterSpacing: -0.5,
}}>
{displayName}
</span>
{groupName && (
<span style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: 0.8,
color: cfg.nameText + '80',
marginTop: 1,
textTransform: 'uppercase',
}}>
{groupName}
</span>
)}
</div>
{/* Bottom-left: status badge */}
<div style={{
position: 'absolute', bottom: 11, left: 11,
background: cfg.badgeBg,
borderRadius: 5,
padding: '2px 8px',
}}>
<span style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.5,
color: cfg.badgeText,
whiteSpace: 'nowrap',
}}>
{STATUS_LABELS[statusKey]}
</span>
</div>
{/* Bottom-right: flag circles, stacked, up to 3 visible */}
{flags.length > 0 && (
<div style={{
position: 'absolute', bottom: 8, right: 10,
display: 'flex', flexDirection: 'column-reverse', gap: 4,
}}>
{flags.slice(0, 3).map(f => (
<div key={f.id} style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
}}>
{f.emoji || '🏷️'}
</div>
))}
{flags.length > 3 && (
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(98,149,243,0.9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: '#fff',
}}>
+{flags.length - 3}
</div>
)}
</div>
)}
</button> </button>
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
{showTip && flags.length > 0 && ( {showTip && flags.length > 0 && (
<div style={{ <div style={{
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0, position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
background: 'var(--bg2)', border: '1px solid var(--border)', background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 10, padding: '8px 12px', zIndex: 50, borderRadius: 10, padding: '8px 12px', zIndex: 50,
boxShadow: '0 4px 16px var(--shadow)', boxShadow: '0 4px 16px var(--shadow)',
minWidth: 160, minWidth: 160, pointerEvents: 'none',
pointerEvents: 'none',
}}> }}>
{flags.map(f => ( {flags.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}> <div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>

View File

@@ -168,6 +168,12 @@ export default function UserMenu() {
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span> <span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
</button> </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" /> <div className="user-menu-divider" />
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}> <button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>

View File

@@ -52,9 +52,8 @@ function NotificationBanner({ message, onAck }) {
export function NotificationProvider({ children }) { export function NotificationProvider({ children }) {
const { token, user } = useAuthStore() const { token, user } = useAuthStore()
const [pendingMessages, setPendingMessages] = useState([]) // unacked const [pendingMessages, setPendingMessages] = useState([])
const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history) const [recentMessages, setRecentMessages] = useState([])
const pollRef = useRef(null)
const fetchUnread = useCallback(async () => { const fetchUnread = useCallback(async () => {
if (!token || !user) return if (!token || !user) return
@@ -72,14 +71,62 @@ export function NotificationProvider({ children }) {
} catch { } } catch { }
}, [token, user?.id]) }, [token, user?.id])
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
useEffect(() => { useEffect(() => {
if (!token || !user) return if (!token || !user) return
fetchUnread() fetchUnread()
fetchRecent() fetchRecent()
pollRef.current = setInterval(fetchUnread, 2000) const id = setInterval(fetchUnread, 5000)
return () => clearInterval(pollRef.current) return () => clearInterval(id)
}, [token, user?.id]) }, [token, user?.id])
// SSE message_sent events → add to pending without polling
useEffect(() => {
function onSSEEvent(e) {
const { type, data } = e.detail
if (type !== 'message_sent') return
if (!user) return
// Check if this message targets us (empty = broadcast)
const targets = data.target_waiter_ids || []
if (targets.length > 0 && !targets.includes(user.id)) return
const msg = {
id: data.id,
sender_id: data.sender_id,
sender_name: data.sender_name,
body: data.body,
table_ids: data.table_ids,
created_at: data.created_at,
acked_by: [],
}
setPendingMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev
return [msg, ...prev]
})
setRecentMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev
return [msg, ...prev].slice(0, 10)
})
}
window.addEventListener('sse-event', onSSEEvent)
return () => window.removeEventListener('sse-event', onSSEEvent)
}, [user?.id])
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
useEffect(() => {
function onSSEConnect() {
fetchUnread()
fetchRecent()
}
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
// through the backend-coming-back-online signal that SSEProvider dispatches
window.addEventListener('sse-reconnected', onSSEConnect)
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
}, [fetchUnread, fetchRecent])
async function ackMessage(messageId) { async function ackMessage(messageId) {
try { try {
await client.post(`/api/messages/${messageId}/ack`) await client.post(`/api/messages/${messageId}/ack`)
@@ -91,7 +138,7 @@ export function NotificationProvider({ children }) {
const unreadCount = pendingMessages.length const unreadCount = pendingMessages.length
return ( return (
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}> <NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
{children} {children}
{/* Floating banner stack (max 3 visible) */} {/* Floating banner stack (max 3 visible) */}

View File

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

View File

@@ -0,0 +1,15 @@
import Dexie from 'dexie'
/**
* Local IndexedDB snapshot — written by SSE events and full GETs.
* Read-only in Emergency Mode when the server is unreachable.
*/
const db = new Dexie('pos_snapshot')
db.version(1).stores({
tables: 'id, group_id, is_active', // TableOut snapshots
orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots
offline_payments: '++localId, uuid, synced', // queued emergency payments
})
export default db

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } from 'react'
const BASE_URL = import.meta.env.VITE_API_URL || 'http://192.168.1.10:8000'
const INITIAL_RECONNECT_DELAY = 3000
const MAX_RECONNECT_DELAY = 30000
/**
* Opens an SSE connection to /api/sse/stream?token=<jwt>.
*
* Callbacks (onEvent, onConnect, onDisconnect) are stored in refs so they are
* always current without causing the EventSource to reconnect when they change.
*
* The connection is created/destroyed only when `token` or `enabled` changes.
*/
export function useSSE({ token, onEvent, onConnect, onDisconnect, enabled = true }) {
// Keep callbacks in refs so the EventSource closure always calls the latest version
const onEventRef = useRef(onEvent)
const onConnectRef = useRef(onConnect)
const onDisconnectRef = useRef(onDisconnect)
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
useEffect(() => { onConnectRef.current = onConnect }, [onConnect])
useEffect(() => { onDisconnectRef.current = onDisconnect }, [onDisconnect])
const esRef = useRef(null)
const reconnectTimer = useRef(null)
const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY)
const unmounted = useRef(false)
// Expose reconnect so SSEContext can trigger it after heartbeat recovery
const reconnectRef = useRef(null)
useEffect(() => {
if (!token || !enabled) return
unmounted.current = false
function connect() {
if (unmounted.current) return
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
const url = `${BASE_URL}/api/sse/stream?token=${encodeURIComponent(token)}`
const es = new EventSource(url)
esRef.current = es
es.onopen = () => {
reconnectDelay.current = INITIAL_RECONNECT_DELAY
onConnectRef.current?.()
}
es.onmessage = (e) => {
try {
const { type, data } = JSON.parse(e.data)
onEventRef.current?.(type, data)
} catch {
// malformed event — ignore
}
}
es.onerror = () => {
es.close()
esRef.current = null
onDisconnectRef.current?.()
if (unmounted.current) return
reconnectTimer.current = setTimeout(() => {
reconnectDelay.current = Math.min(
reconnectDelay.current * 1.5,
MAX_RECONNECT_DELAY
)
connect()
}, reconnectDelay.current)
}
}
reconnectRef.current = connect
connect()
return () => {
unmounted.current = true
clearTimeout(reconnectTimer.current)
esRef.current?.close()
esRef.current = null
}
}, [token, enabled])
// Stable reference — never changes, so heartbeat useEffect dep array stays stable
const reconnect = useCallback(() => {
clearTimeout(reconnectTimer.current)
reconnectDelay.current = INITIAL_RECONNECT_DELAY
reconnectRef.current?.()
}, [])
return { reconnect }
}

View File

@@ -211,70 +211,23 @@ html, body {
.text-input:focus { border-color: var(--accent); } .text-input:focus { border-color: var(--accent); }
.error-msg { color: var(--danger); font-size: 14px; text-align: center; } .error-msg { color: var(--danger); font-size: 14px; text-align: center; }
/* ── Filter Tabs ─────────────────────────────────────────── */ /* ── Zone Tab Bar (replaces old filter-tabs) ─────────────── */
.filter-tabs { .zone-tab-bar {
display: flex; display: flex;
gap: 8px; align-items: center;
padding: 12px 16px; gap: 6px;
padding: 10px 16px;
background: var(--bg); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
} }
.filter-tab { .zone-tab-bar::-webkit-scrollbar { display: none; }
flex: 1;
padding: 10px;
border-radius: 8px;
border: none;
background: var(--bg2);
color: var(--muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.filter-tab--active { background: var(--accent); color: var(--accent-fg); }
/* ── Table Grid ──────────────────────────────────────────── */ /* ── Table Grid — density-driven via inline style ─────────── */
.table-grid { /* Cards use inline styles per density, grid columns come from JS */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
align-content: start;
}
.table-card-v2 {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 12px 12px 48px;
width: 100%;
min-height: 116px;
border-radius: 16px;
border: none;
cursor: pointer;
text-align: left;
overflow: hidden;
transition: transform 0.12s;
box-shadow: 0 2px 10px var(--shadow);
}
.table-card-v2:active { transform: scale(0.96); } .table-card-v2:active { transform: scale(0.96); }
/* ── FAB ─────────────────────────────────────────────────── */
.fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent);
color: var(--accent-fg);
font-size: 24px;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px var(--shadow);
}
/* ── Cart badge ──────────────────────────────────────────── */ /* ── Cart badge ──────────────────────────────────────────── */
.cart-badge { .cart-badge {
position: absolute; position: absolute;
@@ -315,20 +268,10 @@ html, body {
align-items: stretch; align-items: stretch;
overflow: hidden; overflow: hidden;
} }
.category-tabs__fade {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
background: linear-gradient(to right, var(--bg2) 40%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.category-tabs__scroll { .category-tabs__scroll {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 10px 12px 10px 36px; padding: 10px 12px;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;

View File

@@ -20,6 +20,10 @@ export default function AddItemsPage() {
const [printAck, setPrintAck] = useState(null) const [printAck, setPrintAck] = useState(null)
const [cartOpen, setCartOpen] = useState(false) const [cartOpen, setCartOpen] = useState(false)
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState } const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
const [viewAllOpen, setViewAllOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -310,31 +314,55 @@ export default function AddItemsPage() {
<header className="top-bar"> <header className="top-bar">
<button className="icon-btn" onClick={handleBack}></button> <button className="icon-btn" onClick={handleBack}></button>
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span> <span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
{/* Cart icon with badge — opens side drawer */} <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button {/* Search button */}
className="icon-btn" <button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
style={{ position: 'relative' }} <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
onClick={() => setCartOpen(true)} <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
> <path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"> </svg>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> </button>
</svg> {/* Categories button */}
{cart.length > 0 && ( <button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
<span style={{ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
position: 'absolute', top: -2, right: -2, <rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
minWidth: 18, height: 18, borderRadius: 9, <rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
background: 'var(--accent)', color: 'var(--accent-fg)', <rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
fontSize: 11, fontWeight: 800, <rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
display: 'flex', alignItems: 'center', justifyContent: 'center', </svg>
padding: '0 4px', </button>
}}>{cart.length}</span> {/* Cart button with badge */}
)} <button
</button> className="icon-btn"
style={{ position: 'relative' }}
onClick={() => setCartOpen(true)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{cart.length > 0 && (
<span style={{
position: 'absolute', top: -2, right: -2,
minWidth: 18, height: 18, borderRadius: 9,
background: 'var(--accent)', color: 'var(--accent-fg)',
fontSize: 11, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 4px',
}}>{cart.length}</span>
)}
</button>
</div>
</header> </header>
{/* Product picker takes all remaining space */} {/* Product picker takes all remaining space */}
{categories.length > 0 && ( {categories.length > 0 && (
<ProductPicker categories={categories} products={products} onAdd={addToCart} /> <ProductPicker
categories={categories}
products={products}
onAdd={addToCart}
viewAllOpen={viewAllOpen}
setViewAllOpen={setViewAllOpen}
/>
)} )}
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */} {/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
@@ -382,17 +410,12 @@ export default function AddItemsPage() {
className="btn btn--primary btn--lg" className="btn btn--primary btn--lg"
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }} style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
onClick={sendOrder} onClick={sendOrder}
disabled={cart.length === 0 || sending} disabled={cart.length === 0 || sending || !!printAck?.allOk}
> >
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`} {sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
</button> </button>
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>} {error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
{printAck?.allOk && (
<div style={{ marginTop: 8, background: '#14532d', border: '1px solid #22c55e', borderRadius: 10, padding: '8px 14px', color: '#86efac', fontWeight: 600, fontSize: 13, textAlign: 'center' }}>
Εκτυπώθηκε επιτυχώς μεταφορά
</div>
)}
</div> </div>
{/* ── Cart side drawer ────────────────────────────────────────────────── */} {/* ── Cart side drawer ────────────────────────────────────────────────── */}
@@ -465,7 +488,7 @@ export default function AddItemsPage() {
className="btn btn--primary btn--lg" className="btn btn--primary btn--lg"
style={{ width: '100%' }} style={{ width: '100%' }}
onClick={sendOrder} onClick={sendOrder}
disabled={cart.length === 0 || sending} disabled={cart.length === 0 || sending || !!printAck?.allOk}
> >
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`} {sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
</button> </button>
@@ -483,6 +506,46 @@ export default function AddItemsPage() {
initialState={editItem.drawerState} initialState={editItem.drawerState}
/> />
)} )}
{/* ── Search modal ─────────────────────────────────────────────────────── */}
{searchOpen && (
<SearchModal
products={products}
query={searchQuery}
setQuery={setSearchQuery}
onClose={() => setSearchOpen(false)}
onAdd={item => { addToCart(item); setSearchOpen(false) }}
/>
)}
{/* Full-screen success overlay — blocks all interaction while navigating */}
{printAck?.allOk && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.72)',
animation: 'fadeInOverlay 180ms ease',
}}>
<div style={{
background: '#14532d', border: '2px solid #22c55e',
borderRadius: 20, padding: '36px 48px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
animation: 'popIn 220ms cubic-bezier(0.34,1.56,0.64,1)',
}}>
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" stroke="#22c55e" strokeWidth="2"/>
<path d="M7 12.5l3.5 3.5 6.5-7" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#86efac', fontWeight: 700, fontSize: 18, letterSpacing: 0.3 }}>
Εκτυπώθηκε Επιτυχώς
</span>
</div>
<style>{`
@keyframes fadeInOverlay { from { opacity: 0 } to { opacity: 1 } }
@keyframes popIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
`}</style>
</div>
)}
</div> </div>
) )
} }
@@ -638,3 +701,144 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
</div> </div>
) )
} }
// ── Search Modal ──────────────────────────────────────────────────────────────
const API_URL = import.meta.env.VITE_API_URL || ''
function SearchModal({ products, query, setQuery, onClose, onAdd }) {
const [drawerProduct, setDrawerProduct] = useState(null)
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
const results = query.trim().length === 0
? []
: activeProducts.filter(p =>
p.name.toLowerCase().includes(query.trim().toLowerCase())
)
function openProduct(p) {
// Blur the input first so the keyboard dismisses, then open the drawer
document.activeElement?.blur()
setDrawerProduct(p)
}
// The modal is position:fixed anchored to bottom:0.
// When the soft keyboard opens on mobile the browser shrinks the visual
// viewport and fixed elements reposition automatically — the panel sits
// right on top of the keyboard without any JS measurement needed.
return (
<>
{/* Dim backdrop — tap to close */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }} />
{/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't
push the input off screen on short viewports */}
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0,
zIndex: 201,
background: 'var(--bg)',
borderTop: '1px solid var(--border)',
display: 'flex', flexDirection: 'column',
maxHeight: '60vh',
}}>
{/* Results scroll area — flex:1 so it takes space above the input */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{query.trim().length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
Πληκτρολογήστε για αναζήτηση
</p>
) : results.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
Δεν βρέθηκαν προϊόντα για «{query}»
</p>
) : results.map(p => {
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<button
key={p.id}
onClick={() => openProduct(p)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
width: '100%', padding: '10px 16px',
background: 'none', border: 'none', cursor: 'pointer',
borderBottom: '1px solid var(--border)',
textAlign: 'left',
}}
>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: 'var(--bg3)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{p.image_url
? <img src={`${API_URL}${p.image_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
{Number(p.base_price).toFixed(2)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)
})}
</div>
{/* Search input — pinned at the bottom of the panel, above the keyboard */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px 12px',
borderTop: '1px solid var(--border)',
flexShrink: 0,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Αναζήτηση προϊόντος…"
style={{
flex: 1, height: 44, background: 'var(--bg2)',
border: '1px solid var(--border)', borderRadius: 12,
padding: '0 12px', fontSize: 16, color: 'var(--text)',
fontFamily: 'inherit', outline: 'none',
}}
/>
<button
onClick={onClose}
style={{
background: 'var(--bg3)', border: 'none', borderRadius: '50%',
width: 36, height: 36, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--text)',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
</div>
</div>
{/* Product drawer — closes search modal when item is added */}
{drawerProduct && (
<OrderDrawer
product={drawerProduct}
isOpen
onClose={() => setDrawerProduct(null)}
onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }}
/>
)}
</>
)
}

View File

@@ -81,14 +81,19 @@ export default function LoginPage() {
const [waiters, setWaiters] = useState([]) const [waiters, setWaiters] = useState([])
const [loadingWaiters, setLoadingWaiters] = useState(true) const [loadingWaiters, setLoadingWaiters] = useState(true)
const [serverUnreachable, setServerUnreachable] = useState(false)
const [selectedWaiter, setSelectedWaiter] = useState(null) const [selectedWaiter, setSelectedWaiter] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
client.get('/api/auth/waiters') client.get('/api/auth/waiters')
.then(r => setWaiters(r.data)) .then(r => { setWaiters(r.data); setServerUnreachable(false) })
.catch(() => setWaiters([])) .catch(err => {
// No response = network error = server unreachable
if (!err.response) setServerUnreachable(true)
setWaiters([])
})
.finally(() => setLoadingWaiters(false)) .finally(() => setLoadingWaiters(false))
}, []) }, [])
@@ -130,6 +135,30 @@ export default function LoginPage() {
<div style={{ maxWidth: 480, margin: '0 auto' }}> <div style={{ maxWidth: 480, margin: '0 auto' }}>
{loadingWaiters ? ( {loadingWaiters ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση</p> <p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση</p>
) : serverUnreachable ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔌</div>
<p style={{ fontSize: 17, fontWeight: 700, color: '#ef4444', marginBottom: 8 }}>
Δεν βρέθηκε ο Server
</p>
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 24 }}>
Δεν είναι δυνατή η σύνδεση με τον Manager.<br />
Δεν μπορεί να ξεκινήσει βάρδια χωρίς σύνδεση.
</p>
<button
className="btn btn--secondary"
onClick={() => {
setLoadingWaiters(true)
setServerUnreachable(false)
client.get('/api/auth/waiters')
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
.catch(err => { if (!err.response) setServerUnreachable(true) })
.finally(() => setLoadingWaiters(false))
}}
>
Επανάληψη
</button>
</div>
) : waiters.length === 0 ? ( ) : waiters.length === 0 ? (
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p> <p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
) : ( ) : (

View File

@@ -0,0 +1,345 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import useTableViewStore from '../store/tableViewStore'
import useThemeStore from '../store/themeStore'
// ─── Tab definitions (stub future tabs here) ──────────────────────────────────
const TABS = [
{ key: 'layout', label: 'Εμφάνιση' },
{ key: 'favorites', label: 'Αγαπημένα', disabled: true },
]
// ─── Density option data ──────────────────────────────────────────────────────
const DENSITY_OPTIONS = [
{
key: '1x1',
label: '1×1',
desc: '4 ανά σειρά — μόνο όνομα',
preview: <Grid4 />,
},
{
key: '2x1',
label: '2×1',
desc: '2 ανά σειρά — όνομα + κατάσταση',
preview: <Grid2H />,
},
{
key: '2x2',
label: '2×2',
desc: '2 ανά σειρά — συμπαγής κάρτα',
preview: <Grid2 />,
},
{
key: '4x1',
label: '4×1',
desc: '1 ανά σειρά — οριζόντια λίστα',
preview: <Grid1H />,
},
{
key: '4x2',
label: '4×2',
desc: '1 ανά σειρά — πλήρης κάρτα',
preview: <Grid1 />,
},
{
key: '4x3',
label: '4×3',
desc: '1 ανά σειρά — κάρτα με λίστα παραγγελίας',
preview: <Grid1Detail />,
},
]
// ─── Mini grid preview SVGs ───────────────────────────────────────────────────
function Grid4() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
{[0,1,2,3].map(i => (
<rect key={i} x={2 + i * 13} y="4" width="11" height="13" rx="2" fill="currentColor" opacity="0.9"/>
))}
{[0,1,2,3].map(i => (
<rect key={i+4} x={2 + i * 13} y="20" width="11" height="13" rx="2" fill="currentColor" opacity="0.55"/>
))}
{[0,1,2,3].map(i => (
<rect key={i+8} x={2 + i * 13} y="36" width="11" height="13" rx="2" fill="currentColor" opacity="0.25"/>
))}
</svg>
)
}
function Grid2H() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
{[0,1].map(i => (
<rect key={i} x={2 + i * 27} y="4" width="25" height="11" rx="2" fill="currentColor" opacity="0.9"/>
))}
{[0,1].map(i => (
<rect key={i+2} x={2 + i * 27} y="19" width="25" height="11" rx="2" fill="currentColor" opacity="0.55"/>
))}
{[0,1].map(i => (
<rect key={i+4} x={2 + i * 27} y="34" width="25" height="11" rx="2" fill="currentColor" opacity="0.25"/>
))}
</svg>
)
}
function Grid2() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="30" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
<rect x="30" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
function Grid1H() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="11" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="19" width="52" height="11" rx="2" fill="currentColor" opacity="0.55"/>
<rect x="2" y="34" width="52" height="11" rx="2" fill="currentColor" opacity="0.25"/>
</svg>
)
}
function Grid1() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="18" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="2" y="27" width="52" height="18" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
function Grid1Detail() {
return (
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
<rect x="2" y="4" width="52" height="20" rx="2" fill="currentColor" opacity="0.9"/>
{/* left section lines */}
<rect x="5" y="8" width="14" height="3" rx="1" fill="white" opacity="0.6"/>
<rect x="5" y="13" width="9" height="2" rx="1" fill="white" opacity="0.4"/>
<rect x="5" y="18" width="11" height="2" rx="1" fill="white" opacity="0.4"/>
{/* vertical divider */}
<rect x="22" y="7" width="1" height="14" rx="0.5" fill="white" opacity="0.3"/>
{/* right section lines */}
<rect x="25" y="8" width="24" height="2" rx="1" fill="white" opacity="0.5"/>
<rect x="25" y="12" width="20" height="2" rx="1" fill="white" opacity="0.35"/>
<rect x="25" y="16" width="22" height="2" rx="1" fill="white" opacity="0.25"/>
<rect x="2" y="29" width="52" height="15" rx="2" fill="currentColor" opacity="0.45"/>
</svg>
)
}
// ─── Layout tab ───────────────────────────────────────────────────────────────
function LayoutTab() {
const { density, setDensity } = useTableViewStore()
const { dark, toggle } = useThemeStore()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, padding: '24px 16px' }}>
{/* Card density */}
<section>
<h2 style={sectionTitle}>Κάρτες τραπεζιών</h2>
<p style={sectionSub}>Επίλεξε πόσα στοιχεία εμφανίζονται σε κάθε κάρτα.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
{DENSITY_OPTIONS.map(opt => {
const active = density === opt.key
return (
<button
key={opt.key}
onClick={() => setDensity(opt.key)}
style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '14px 16px',
borderRadius: 14,
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 0.12s, background 0.12s',
}}
>
{/* Mini preview */}
<div style={{
width: 56, height: 48, flexShrink: 0,
color: active ? 'var(--accent)' : 'var(--muted)',
transition: 'color 0.12s',
}}>
{opt.preview}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 15, fontWeight: 700,
color: active ? 'var(--accent)' : 'var(--text)',
marginBottom: 2,
}}>
{opt.label}
</div>
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.4 }}>
{opt.desc}
</div>
</div>
{/* Check */}
{active && (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0, color: 'var(--accent)' }}>
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5"/>
<path d="M6.5 10l2.5 2.5 4.5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
)
})}
</div>
</section>
{/* Theme */}
<section>
<h2 style={sectionTitle}>Θέμα</h2>
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
{[
{ key: false, icon: '☀️', label: 'Φωτεινό' },
{ key: true, icon: '🌙', label: 'Σκοτεινό' },
].map(opt => {
const active = dark === opt.key
return (
<button
key={String(opt.key)}
onClick={() => { if (!active) toggle() }}
style={{
flex: 1,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
padding: '18px 12px',
borderRadius: 14,
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
cursor: active ? 'default' : 'pointer',
transition: 'border-color 0.12s, background 0.12s',
}}
>
<span style={{ fontSize: 28 }}>{opt.icon}</span>
<span style={{
fontSize: 14, fontWeight: 600,
color: active ? 'var(--accent)' : 'var(--muted)',
}}>{opt.label}</span>
</button>
)
})}
</div>
</section>
</div>
)
}
const sectionTitle = {
fontSize: 13, fontWeight: 700, color: 'var(--muted)',
letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4,
}
const sectionSub = {
fontSize: 14, color: 'var(--muted)', lineHeight: 1.5,
}
// ─── Favorites stub tab ───────────────────────────────────────────────────────
function FavoritesTab() {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 40, flex: 1 }}>
<span style={{ fontSize: 40 }}></span>
<p style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Σύντομα διαθέσιμο</p>
<p style={{ fontSize: 14, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.5 }}>
Τα αγαπημένα προϊόντα θα εμφανίζονται εδώ για γρήγορη παραγγελία.
</p>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('layout')
return (
<div className="page">
{/* Top bar */}
<header className="top-bar">
<button
onClick={() => navigate(-1)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text)', fontSize: 15, fontWeight: 600,
padding: '0 4px', minHeight: 44, borderRadius: 8,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15l-5-5 5-5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Πίσω
</button>
<span className="top-bar__title" style={{ textAlign: 'center' }}>Ρυθμίσεις</span>
{/* spacer to balance the back button */}
<div style={{ width: 72 }} />
</header>
{/* Tab strip */}
<div style={{
display: 'flex', gap: 0,
borderBottom: '1px solid var(--border)',
background: 'var(--bg2)',
padding: '0 16px',
}}>
{TABS.map(tab => (
<button
key={tab.key}
disabled={tab.disabled}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
style={{
padding: '14px 16px',
background: 'none', border: 'none',
borderBottom: activeTab === tab.key ? '2px solid var(--accent)' : '2px solid transparent',
color: tab.disabled
? 'var(--muted)'
: activeTab === tab.key
? 'var(--accent)'
: 'var(--text)',
fontSize: 14, fontWeight: 600,
cursor: tab.disabled ? 'not-allowed' : 'pointer',
opacity: tab.disabled ? 0.45 : 1,
marginBottom: -1, // overlap the border-bottom
whiteSpace: 'nowrap',
transition: 'color 0.12s',
}}
>
{tab.label}
{tab.disabled && (
<span style={{
marginLeft: 6, fontSize: 10, fontWeight: 700,
background: 'var(--bg3)', color: 'var(--muted)',
borderRadius: 4, padding: '1px 5px',
verticalAlign: 'middle',
}}>σύντομα</span>
)}
</button>
))}
</div>
{/* Tab body */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
{activeTab === 'layout' && <LayoutTab />}
{activeTab === 'favorites' && <FavoritesTab />}
</div>
</div>
)
}

View File

@@ -1,22 +1,34 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import TableCard from '../components/TableCard' import TableCard from '../components/TableCard'
import ConnectionBanner from '../components/ConnectionBanner' import ConnectionBanner from '../components/ConnectionBanner'
import EmergencyBar from '../components/EmergencyBar'
import UserMenu from '../components/UserMenu' import UserMenu from '../components/UserMenu'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import useTableColourStore from '../store/tableColourStore' import useTableColourStore from '../store/tableColourStore'
import useConnectionStore from '../store/connectionStore'
import useTableViewStore from '../store/tableViewStore'
import client from '../api/client' import client from '../api/client'
import db from '../db/posdb'
import { queueOfflinePayment } from '../services/offlinePayments'
import { useNotifications } from '../context/NotificationContext' import { useNotifications } from '../context/NotificationContext'
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons' import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
const FILTERS = ['all', 'mine', 'free']
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' } function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
// ─── Notification history drawer ───────────────────────────────────────────── // ─── Icons ────────────────────────────────────────────────────────────────────
function NotificationDrawer({ messages, onClose, onAck }) { function FilterIcon({ size = 20 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
)
}
// ─── Notification drawer ──────────────────────────────────────────────────────
function NotificationDrawer({ messages, onClose }) {
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}> <div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
@@ -37,9 +49,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span> <span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{msg.sender_name && ( {msg.sender_name && (
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}> <div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
{msg.sender_name}
</div>
)} )}
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
{tableIds.length > 0 && ( {tableIds.length > 0 && (
@@ -59,7 +69,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
) )
} }
// ─── Table quick-view + actions popup (long-press) ──────────────────────────── // ─── Table quick-view modal (long press) ──────────────────────────────────────
const QUICK_ACTIONS = [ const QUICK_ACTIONS = [
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' }, { Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
@@ -77,25 +87,18 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
const due = Math.max(0, total - paid) const due = Math.max(0, total - paid)
const statusLabel = { const statusLabel = {
open: 'Ανοιχτό', open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
partially_paid: 'Μερικώς πληρωμένο',
paid: 'Πληρωμένο',
}[order?.status] || 'Ελεύθερο' }[order?.status] || 'Ελεύθερο'
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={onClose}>
{/* Status overview card */}
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}> <div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
<div style={{ <div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
padding: '16px 20px', borderBottom: '1px solid var(--border)',
}}>
<div className="modal-handle" style={{ marginBottom: 12 }} /> <div className="modal-handle" style={{ marginBottom: 12 }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span> <span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span> <span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
</div> </div>
{order ? ( {order ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
@@ -116,7 +119,6 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
) : ( ) : (
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p> <p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
)} )}
{flags.length > 0 && ( {flags.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{flags.map(f => ( {flags.map(f => (
@@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
))} ))}
</div> </div>
)} )}
<button className="btn btn--primary" style={{ width: '100%', marginTop: 14 }} onClick={() => { onClose(); onNavigate() }}>
<button
className="btn btn--primary"
style={{ width: '100%', marginTop: 14 }}
onClick={() => { onClose(); onNavigate() }}
>
Άνοιγμα τραπεζιού Άνοιγμα τραπεζιού
</button> </button>
</div> </div>
<div style={{ background: 'var(--bg2)', borderRadius: '0 0 16px 16px', padding: '8px 20px 24px', borderTop: '2px solid var(--border)' }}>
{/* Quick actions card */} <p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>ACTIONS</p>
<div style={{
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
padding: '8px 20px 24px',
borderTop: '2px solid var(--border)',
}}>
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
ACTIONS
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{QUICK_ACTIONS.map((a, i) => { {QUICK_ACTIONS.map((a, i) => {
const disabled = !order && a.key !== 'flags' const disabled = !order && a.key !== 'flags'
return ( return (
<button <button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
key={a.key} display: 'flex', alignItems: 'center', gap: 14,
disabled={disabled} padding: '12px 0', background: 'none', border: 'none',
onClick={() => { onClose(); onAction(a.key) }} borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
style={{ cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', gap: 14, opacity: disabled ? 0.35 : 1, textAlign: 'left',
padding: '12px 0', background: 'none', border: 'none', }}>
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none', <span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: a.iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', color: a.color }}>
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}
>
<span style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: a.color,
}}>
<a.Icon width="18" height="18" /> <a.Icon width="18" height="18" />
</span> </span>
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span> <span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
@@ -187,27 +166,225 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
) )
} }
// ─── Emergency payment modal ──────────────────────────────────────────────────
function EmergencyPayModal({ table, order, onClose, onPay }) {
const [paying, setPaying] = useState(false)
const activeItems = order?.items?.filter(i => i.status === 'active') || []
const total = activeItems.reduce((s, i) => s + (i.unit_price || 0) * (i.quantity || 1), 0)
async function handlePay() {
setPaying(true)
await onPay(order.id, activeItems.map(i => i.id), 'cash')
onClose()
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<div className="modal-handle" />
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🚨</div>
<p style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ</p>
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>Τραπέζι: <strong>{table.label || `T${table.number}`}</strong></p>
</div>
<div style={{ background: 'var(--bg3)', borderRadius: 12, padding: '12px 16px', marginBottom: 20 }}>
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>Ενεργά αντικείμενα:</p>
{activeItems.length === 0
? <p style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>Δεν υπάρχουν δεδομένα (offline snapshot)</p>
: activeItems.map(item => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, marginBottom: 4 }}>
<span style={{ color: 'var(--text)' }}>{item.product?.name || `#${item.product_id}`} ×{item.quantity}</span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} </span>
</div>
))
}
<div style={{ borderTop: '1px solid var(--border)', marginTop: 10, paddingTop: 10, display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 16 }}>
<span>Σύνολο</span>
<span style={{ color: '#ef4444' }}>{total.toFixed(2)} </span>
</div>
</div>
{total === 0
? <p style={{ fontSize: 13, color: '#ef4444', marginBottom: 16, lineHeight: 1.5, fontWeight: 600 }}>
Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.
</p>
: <p style={{ fontSize: 12, color: '#f59e0b', marginBottom: 16, lineHeight: 1.5 }}>
Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.
</p>
}
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Ακύρωση</button>
<button
style={{ flex: 1, height: 44, borderRadius: 12, border: 'none', background: total === 0 ? '#64748b' : '#dc2626', color: '#fff', fontSize: 15, fontWeight: 700, cursor: (paying || total === 0) ? 'not-allowed' : 'pointer', opacity: (paying || total === 0) ? 0.5 : 1 }}
onClick={handlePay} disabled={paying || total === 0}
>
{paying ? '⟳ Καταχώρηση…' : '✓ Πληρωμή'}
</button>
</div>
</div>
</div>
)
}
// ─── Filters modal ────────────────────────────────────────────────────────────
function FiltersModal({ groups, onClose }) {
const {
ownerFilter, statusFilter, zoneFilter,
setOwnerFilter, setStatusFilter, setZoneFilter,
clearFilters, setActiveZoneTab,
} = useTableViewStore()
function toggleZone(id) {
const next = zoneFilter.includes(id)
? zoneFilter.filter(z => z !== id)
: [...zoneFilter, id]
setZoneFilter(next)
// if we remove a zone that is the active tab, reset to 'all'
if (!next.length) setActiveZoneTab('all')
}
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
return (
<div className="modal-overlay" onClick={onClose} style={{ alignItems: 'flex-end' }}>
<div
className="modal-sheet"
onClick={e => e.stopPropagation()}
style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }}
>
<div className="modal-handle" />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)' }}>Φίλτρα</span>
{hasActiveFilters && (
<button
onClick={() => { clearFilters(); onClose() }}
style={{ fontSize: 13, fontWeight: 600, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px' }}
>
Καθαρισμός
</button>
)}
</div>
{/* Owner: ALL | MINE */}
<div>
<p style={sectionLabel}>Ανάθεση</p>
<div style={segmentedWrap}>
{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => (
<button key={key} onClick={() => setOwnerFilter(key)} style={segBtn(ownerFilter === key)}>{lbl}</button>
))}
</div>
</div>
{/* Status: ALL | FREE | OPEN | PAID */}
<div>
<p style={sectionLabel}>Κατάσταση</p>
<div style={{ ...segmentedWrap, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => (
<button key={key} onClick={() => setStatusFilter(key)} style={{ ...segBtn(statusFilter === key), borderRadius: 10 }}>{lbl}</button>
))}
</div>
</div>
{/* Zones: multi-select, one segmented container per zone */}
{groups.length > 0 && (
<div>
<p style={sectionLabel}>Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{groups.map(g => {
const active = zoneFilter.includes(g.id)
return (
<div key={g.id} style={segmentedWrap}>
<button
onClick={() => toggleZone(g.id)}
style={{
...segBtn(active),
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
}}
>
{g.color && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: active ? 'currentColor' : g.color,
flexShrink: 0, opacity: active ? 0.9 : 1,
}} />
)}
{g.name}
</button>
</div>
)
})}
</div>
</div>
)}
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Εντάξει</button>
</div>
</div>
)
}
const sectionLabel = { fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 8 }
const segmentedWrap = { display: 'flex', gap: 6, background: 'var(--bg3)', borderRadius: 12, padding: 4 }
function segBtn(active) {
return {
flex: 1, padding: '9px 8px', borderRadius: 9, border: 'none',
cursor: 'pointer', fontWeight: 600, fontSize: 14,
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.12s',
}
}
// ─── Main page ──────────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
export default function TableListPage() { export default function TableListPage() {
const { user } = useAuthStore() const { user } = useAuthStore()
const { status: connStatus } = useConnectionStore()
const isEmergency = connStatus === 'emergency'
const [tables, setTables] = useState([]) const [tables, setTables] = useState([])
const [groups, setGroups] = useState([]) const [groups, setGroups] = useState([])
const [orders, setOrders] = useState([]) const [orders, setOrders] = useState([])
const [flagDefs, setFlagDefs] = useState([]) const [flagDefs, setFlagDefs] = useState([])
const [flagAssignments, setFlagAssignments] = useState([]) const [flagAssignments, setFlagAssignments] = useState([])
const [filter, setFilter] = useState('all') const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [zoneOpen, setZoneOpen] = useState(false)
const [selectedZones, setSelectedZones] = useState(new Set())
const [showNotifs, setShowNotifs] = useState(false) const [showNotifs, setShowNotifs] = useState(false)
const [quickModal, setQuickModal] = useState(null) // { table, order, flags } const [showFilters, setShowFilters] = useState(false)
const zoneRef = useRef(null) const [quickModal, setQuickModal] = useState(null)
const navigate = useNavigate() const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {} // pull-to-refresh state
const [pulling, setPulling] = useState(false)
const [pullY, setPullY] = useState(0)
const [refreshing, setRefreshing] = useState(false)
const pullStart = useRef(null)
const scrollRef = useRef(null)
const PULL_THRESHOLD = 72
const navigate = useNavigate()
const filterBtnRef = useRef(null)
const { unreadCount, recentMessages, fetchRecent } = useNotifications() || {}
const loadFromBackend = useTableColourStore(s => s.loadFromBackend) const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
const {
density, ownerFilter, statusFilter, zoneFilter, activeZoneTab, setActiveZoneTab,
} = useTableViewStore()
// ── Load from IndexedDB when offline ──────────────────────────────────────
const loadFromDB = useCallback(async () => {
const [dbTables, dbOrders] = await Promise.all([db.tables.toArray(), db.orders.toArray()])
setTables(dbTables.filter(t => t.is_active !== false))
setOrders(dbOrders)
setOffline(true)
}, [])
useEffect(() => { if (isEmergency) loadFromDB() }, [isEmergency])
useEffect(() => { useEffect(() => {
const handler = () => setOffline(true) const handler = () => setOffline(true)
window.addEventListener('backend-offline', handler) window.addEventListener('backend-offline', handler)
@@ -215,28 +392,37 @@ export default function TableListPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
function onClick(e) { const handler = () => load()
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false) window.addEventListener('sse-reconnected', handler)
} return () => window.removeEventListener('sse-reconnected', handler)
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, []) }, [])
useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus])
async function load() { async function load() {
try { try {
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes] = await Promise.all([ const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes, waitersRes] = await Promise.all([
client.get('/api/tables/'), client.get('/api/tables/'),
client.get('/api/orders/active'), client.get('/api/orders/active'),
client.get('/api/tables/groups'), client.get('/api/tables/groups'),
client.get('/api/flags/defs'), client.get('/api/flags/defs'),
client.get('/api/flags/assignments'), client.get('/api/flags/assignments'),
client.get('/api/settings/'), client.get('/api/settings/'),
client.get('/api/waiters/on-shift'),
]) ])
setTables(tablesRes.data) setTables(tablesRes.data)
setOrders(ordersRes.data) const fullOrders = await Promise.all(
ordersRes.data.map(o =>
client.get(`/api/orders/${o.id}`)
.then(r => ({ ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [] }))
.catch(() => o)
)
)
setOrders(fullOrders)
setGroups(groupsRes.data) setGroups(groupsRes.data)
setFlagDefs(flagDefsRes.data) setFlagDefs(flagDefsRes.data)
setFlagAssignments(flagAssignRes.data) setFlagAssignments(flagAssignRes.data)
setWaiters(waitersRes.data)
const raw = settingsRes.data?.['ui.table_colours']?.value const raw = settingsRes.data?.['ui.table_colours']?.value
if (raw) loadFromBackend(raw) if (raw) loadFromBackend(raw)
setOffline(false) setOffline(false)
@@ -245,6 +431,48 @@ export default function TableListPage() {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
// ── SSE live updates ───────────────────────────────────────────────────────
useEffect(() => {
if (isEmergency) return
function onSSE(e) {
const { type, data } = e.detail
if (type === 'order_updated' || type === 'order_paid') {
client.get(`/api/orders/${data.order_id}`)
.then(r => {
const full = { ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? [] }
setOrders(prev => {
const exists = prev.find(o => o.id === data.order_id)
return exists ? prev.map(o => o.id === data.order_id ? full : o) : [...prev, full]
})
})
.catch(() => {
setOrders(prev => {
const existing = prev.find(o => o.id === data.order_id)
if (existing) return prev.map(o => o.id === data.order_id ? { ...o, status: data.status, table_id: data.table_id } : o)
return [...prev, { id: data.order_id, table_id: data.table_id, status: data.status, waiter_ids: [] }]
})
})
} else if (type === 'order_closed') {
setOrders(prev => prev.filter(o => o.id !== data.order_id))
} else if (type === 'table_flags_changed') {
client.get('/api/flags/assignments').then(r => setFlagAssignments(r.data)).catch(() => {})
} else if (type === 'table_list_changed') {
client.get('/api/tables/').then(r => setTables(r.data)).catch(() => {})
}
}
window.addEventListener('sse-event', onSSE)
return () => window.removeEventListener('sse-event', onSSE)
}, [isEmergency])
// ── Emergency payment ──────────────────────────────────────────────────────
async function handleEmergencyPay(orderId, itemIds, paymentMethod) {
await queueOfflinePayment({ orderId, itemIds, paymentMethod })
setLocalPaidOrderIds(prev => new Set([...prev, orderId]))
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'paid' } : o))
await db.orders.where('id').equals(orderId).modify({ status: 'paid' })
}
// ── Derived maps ───────────────────────────────────────────────────────────
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f])) const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
const tableFlagsMap = {} const tableFlagsMap = {}
flagAssignments.forEach(a => { flagAssignments.forEach(a => {
@@ -252,36 +480,88 @@ export default function TableListPage() {
const def = flagDefMap[a.flag_id] const def = flagDefMap[a.flag_id]
if (def) tableFlagsMap[a.table_id].push(def) if (def) tableFlagsMap[a.table_id].push(def)
}) })
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w]))
function getOrder(tableId) { function getOrder(tableId) { return orders.find(o => o.table_id === tableId) }
return orders.find(o => o.table_id === tableId) function isMyOrder(order) { return !!(order && user && order.waiter_ids?.includes(user.id)) }
function getOrderWaiters(order) {
if (!order) return []
return (order.waiter_ids || []).map(id => waiterMap[id]).filter(Boolean)
} }
function isMyOrder(order) { // ── Filtering logic ────────────────────────────────────────────────────────
if (!order || !user) return false // Zones visible in top bar = those allowed by zoneFilter (or all if empty)
return order.waiter_ids?.includes(user.id) const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
}
function toggleZone(id) { // visibleGroups = groups shown in the top bar
setSelectedZones(prev => { const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id))
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id) // Validate activeZoneTab against current allowedZoneIds
return next // If the active tab is no longer visible, reset to 'all'
}) const effectiveZoneTab = (
} activeZoneTab === 'all' ||
visibleGroups.some(g => g.id === activeZoneTab)
) ? activeZoneTab : 'all'
const filtered = tables.filter(t => { const filtered = tables.filter(t => {
const order = getOrder(t.id) const order = getOrder(t.id)
if (filter === 'free' && order) return false
if (filter === 'mine' && !isMyOrder(order)) return false // Status filter
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false if (statusFilter === 'free' && order) return false
if (statusFilter === 'open' && (!order || order.status === 'paid' || order.status === 'partially_paid')) return false
if (statusFilter === 'paid' && order?.status !== 'paid' && order?.status !== 'partially_paid') return false
// Owner filter
if (ownerFilter === 'mine' && !isMyOrder(order)) return false
// Zone filter from modal (multi-select restricts which zones are allowed)
if (allowedZoneIds && !allowedZoneIds.has(t.group_id ?? 'none')) return false
// Active zone tab (secondary, single-select within allowed)
if (effectiveZoneTab !== 'all' && t.group_id !== effectiveZoneTab) return false
return true return true
}) })
const zoneActive = selectedZones.size > 0 // ── Pull-to-refresh handlers ───────────────────────────────────────────────
function onPullTouchStart(e) {
if (scrollRef.current?.scrollTop > 0) return
pullStart.current = e.touches[0].clientY
}
function onPullTouchMove(e) {
if (pullStart.current === null) return
const dy = e.touches[0].clientY - pullStart.current
if (dy > 0 && scrollRef.current?.scrollTop <= 0) {
e.preventDefault()
setPulling(true)
setPullY(Math.min(dy, PULL_THRESHOLD * 1.5))
}
}
async function onPullTouchEnd() {
if (!pulling) return
if (pullY >= PULL_THRESHOLD) {
setRefreshing(true)
await load()
setRefreshing(false)
}
setPulling(false)
setPullY(0)
pullStart.current = null
}
// ── Grid columns per density ───────────────────────────────────────────────
const gridCols = {
'1x1': 'repeat(4, 1fr)',
'2x1': 'repeat(2, 1fr)',
'2x2': 'repeat(2, 1fr)',
'4x1': '1fr',
'4x2': '1fr',
'4x3': '1fr',
}[density] || 'repeat(2, 1fr)'
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
function handleQuickAction(tableId, actionKey) { function handleQuickAction(tableId, actionKey) {
// Navigate to table then trigger action via URL param so TableDetailPage can handle it
navigate(`/tables/${tableId}?action=${actionKey}`) navigate(`/tables/${tableId}?action=${actionKey}`)
} }
@@ -299,15 +579,14 @@ export default function TableListPage() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}} }}
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="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"/> <path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
</svg> </svg>
{(unreadCount || 0) > 0 && ( {(unreadCount || 0) > 0 && (
<span style={{ <span style={{
position: 'absolute', top: 6, right: 6, position: 'absolute', top: 6, right: 6,
background: '#ef4444', color: 'white', background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
fontSize: 10, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16, borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
@@ -319,109 +598,135 @@ export default function TableListPage() {
<UserMenu /> <UserMenu />
</header> </header>
{offline && <ConnectionBanner />} {isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
<div className="filter-tabs"> {/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
{FILTERS.map(f => ( <div style={{
<button key={f} className={`filter-tab ${filter === f ? 'filter-tab--active' : ''}`} onClick={() => setFilter(f)}> display: 'flex', alignItems: 'center', gap: 6,
{FILTER_LABELS[f]} padding: '10px 12px',
</button> background: 'var(--bg)',
borderBottom: '1px solid var(--border)',
overflowX: 'auto', scrollbarWidth: 'none',
}}>
{/* ALL tab */}
<ZoneTab
label="Όλα"
active={effectiveZoneTab === 'all'}
onClick={() => setActiveZoneTab('all')}
/>
{/* Per-zone tabs */}
{visibleGroups.map(g => (
<ZoneTab
key={g.id}
label={g.name}
color={g.color}
active={effectiveZoneTab === g.id}
onClick={() => setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)}
/>
))} ))}
<div ref={zoneRef} style={{ position: 'relative' }}>
<button
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
onClick={() => setZoneOpen(o => !o)}
>
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
</button>
{zoneOpen && (
<div style={{
position: 'absolute', top: '110%', right: 0, zIndex: 100,
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
}}>
<button
onClick={() => setSelectedZones(new Set())}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Όλες οι ζώνες
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => toggleZone(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
{g.name}
</button>
))}
{tables.some(t => !t.group_id) && (
<button
onClick={() => toggleZone('none')}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
Χωρίς ζώνη
</button>
)}
</div>
)}
</div>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}> {/* ── Table grid ───────────────────────────────────────────────────────── */}
<div className="table-grid"> <div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}
onTouchStart={onPullTouchStart}
onTouchMove={onPullTouchMove}
onTouchEnd={onPullTouchEnd}
>
{/* Pull-to-refresh indicator */}
{(pulling || refreshing) && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: Math.min(pullY, PULL_THRESHOLD),
color: 'var(--muted)', fontSize: 13, fontWeight: 600,
overflow: 'hidden', transition: pulling ? 'none' : 'height 0.2s',
}}>
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: density === '1x1' ? 8 : 10,
padding: '12px 12px 88px',
alignContent: 'start',
}}>
{filtered.map(t => { {filtered.map(t => {
const order = getOrder(t.id) const order = getOrder(t.id)
const tableFlags = tableFlagsMap[t.id] || [] const tableFlags = tableFlagsMap[t.id] || []
const grp = groups.find(g => g.id === t.group_id) const grp = groups.find(g => g.id === t.group_id)
// Free tables go straight to the item picker; occupied tables go to detail const alreadyPaidLocally = order && localPaidOrderIds.has(order.id)
const destination = order const orderWaiters = getOrderWaiters(order)
? `/tables/${t.id}`
: `/tables/${t.id}/add?new=1` function handleClick() {
if (isEmergency) {
if (order && !alreadyPaidLocally && order.status !== 'paid' && order.status !== 'closed') {
setEmergencyPayModal({ table: t, order })
}
return
}
const destination = order ? `/tables/${t.id}` : `/tables/${t.id}/add?new=1`
navigate(destination)
}
return ( return (
<TableCard <TableCard
key={t.id} key={t.id}
table={t} table={t}
order={order} order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
isMine={isMyOrder(order)} isMine={isMyOrder(order)}
flags={tableFlags} flags={tableFlags}
groupName={grp?.name || ''} groupName={grp?.name || ''}
onClick={() => navigate(destination)} waiterObjects={orderWaiters}
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })} density={density}
onClick={handleClick}
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
/> />
) )
})} })}
</div> </div>
<button className="fab" onClick={load} title="Ανανέωση"></button>
</div> </div>
{/* ── Filter FAB ───────────────────────────────────────────────────────── */}
<button
ref={filterBtnRef}
onClick={() => setShowFilters(true)}
style={{
position: 'fixed', bottom: 24, right: 24,
width: 52, height: 52, borderRadius: '50%', border: 'none',
background: hasActiveFilters ? '#ea6c00' : '#f97316',
color: '#fff',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 16px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.2)',
zIndex: 40,
transition: 'background 0.12s',
}}
>
<FilterIcon size={20} />
{hasActiveFilters && (
<span style={{
position: 'absolute', top: 0, right: 0,
background: '#ef4444', color: '#fff',
fontSize: 9, fontWeight: 800,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{(ownerFilter !== 'all' ? 1 : 0) + (statusFilter !== 'all' ? 1 : 0) + (zoneFilter.length > 0 ? 1 : 0)}
</span>
)}
</button>
{/* ── Modals ────────────────────────────────────────────────────────────── */}
{showNotifs && ( {showNotifs && (
<NotificationDrawer <NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
messages={recentMessages || []} )}
onClose={() => setShowNotifs(false)}
onAck={ackMessage} {showFilters && (
/> <FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
)} )}
{quickModal && ( {quickModal && (
@@ -434,6 +739,43 @@ export default function TableListPage() {
onAction={(key) => handleQuickAction(quickModal.table.id, key)} onAction={(key) => handleQuickAction(quickModal.table.id, key)}
/> />
)} )}
{emergencyPayModal && (
<EmergencyPayModal
table={emergencyPayModal.table}
order={emergencyPayModal.order}
onClose={() => setEmergencyPayModal(null)}
onPay={handleEmergencyPay}
/>
)}
</div> </div>
) )
} }
// ─── Zone tab pill ────────────────────────────────────────────────────────────
function ZoneTab({ label, color, active, onClick }) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', borderRadius: 20, border: 'none',
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
fontWeight: 600, fontSize: 13,
background: active ? 'var(--accent)' : 'var(--bg3)',
color: active ? 'var(--accent-fg)' : 'var(--muted)',
transition: 'background 0.12s, color 0.12s',
}}
>
{color && (
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: color, flexShrink: 0,
opacity: active ? 1 : 0.7,
}} />
)}
{label}
</button>
)
}

View File

@@ -0,0 +1,61 @@
import db from '../db/posdb'
import client from '../api/client'
/**
* Queue an emergency payment locally.
* Called in Emergency Mode when the server is unreachable.
*/
export async function queueOfflinePayment({ orderId, itemIds, paymentMethod }) {
const uuid = crypto.randomUUID()
await db.offline_payments.add({
uuid,
orderId,
itemIds,
paymentMethod,
offlineAt: new Date().toISOString(),
synced: 0,
isDuplicate: 0,
})
return uuid
}
/**
* Flush all unsynced offline payments to the server.
* Called when the server comes back online.
* Returns a summary of { synced, duplicates, failed }.
*/
export async function flushOfflinePayments() {
// Boolean is not a valid IndexedDB key — load all and filter in JS
const all = await db.offline_payments.toArray()
const pending = all.filter(p => !p.synced)
const results = { synced: 0, duplicates: 0, failed: 0 }
for (const payment of pending) {
try {
const res = await client.post(`/api/orders/${payment.orderId}/pay-offline`, {
uuid: payment.uuid,
item_ids: payment.itemIds,
payment_method: payment.paymentMethod,
offline_at: payment.offlineAt,
})
const isDuplicate = res.data.is_duplicate
await db.offline_payments.update(payment.localId, {
synced: 1,
isDuplicate: isDuplicate ? 1 : 0,
})
isDuplicate ? results.duplicates++ : results.synced++
} catch {
results.failed++
}
}
return results
}
/**
* Count unsynced pending payments (to show badge / warning).
*/
export async function pendingPaymentCount() {
const all = await db.offline_payments.toArray()
return all.filter(p => !p.synced).length
}

View File

@@ -0,0 +1,33 @@
import { create } from 'zustand'
/**
* Tracks the live connection state and emergency mode flag.
*
* States:
* 'online' — server reachable, SSE connected, normal operation
* 'lost' — server unreachable, modal shown (Wait / Emergency)
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
*/
const useConnectionStore = create((set, get) => ({
status: 'online', // 'online' | 'lost' | 'emergency'
lostAt: null, // Date when connection was lost
setLost: () => {
if (get().status === 'online') {
set({ status: 'lost', lostAt: new Date() })
}
},
setOnline: () => set({ status: 'online', lostAt: null }),
enterEmergency: () => set({ status: 'emergency' }),
// Called when server comes back while in emergency mode — triggers sync then go online
exitEmergency: () => set({ status: 'online', lostAt: null }),
isOnline: () => get().status === 'online',
isLost: () => get().status === 'lost',
isEmergency: () => get().status === 'emergency',
}))
export default useConnectionStore

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3'
// ownerFilter: 'all' | 'mine'
// statusFilter: 'all' | 'free' | 'open' | 'paid'
// zoneFilter: Set of zone IDs (serialized as array in localStorage)
// activeZoneTab: zone id string or 'all'
const useTableViewStore = create(
persist(
(set, get) => ({
density: '2x2',
ownerFilter: 'all',
statusFilter: 'all',
zoneFilter: [], // array of zone ids (serialized fine in JSON)
activeZoneTab: 'all',
setDensity: (density) => set({ density }),
setOwnerFilter: (ownerFilter) => set({ ownerFilter }),
setStatusFilter: (statusFilter) => set({ statusFilter }),
setZoneFilter: (zoneFilter) => set({ zoneFilter }),
setActiveZoneTab: (activeZoneTab) => set({ activeZoneTab }),
clearFilters: () => set({
ownerFilter: 'all',
statusFilter: 'all',
zoneFilter: [],
activeZoneTab: 'all',
}),
}),
{
name: 'table-view-prefs',
// future: could sync to backend here
}
)
)
export default useTableViewStore

View File

@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ export default defineConfig({
server: { server: {
allowedHosts: 'all', allowedHosts: ['all','pos-waiter.bonamin.gr'],
}, },
plugins: [ plugins: [
react(), react(),