Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc
This commit is contained in:
@@ -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"}}}}
|
||||
39
CLAUDE_DESIGN/Table Grid Densities.html
Normal file
39
CLAUDE_DESIGN/Table Grid Densities.html
Normal 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>
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
@@ -39,17 +39,58 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// 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: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-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' 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}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.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}',
|
||||
'[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');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
@@ -58,8 +99,9 @@ const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// 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
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
@@ -115,11 +157,19 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
const abs = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
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 };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
@@ -183,11 +233,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
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 { x, y, scale } = tf.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(() => {
|
||||
@@ -272,6 +359,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
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('gesturestart', onGestureStart, { 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('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
@@ -336,8 +454,13 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = 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)) || {};
|
||||
// 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 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]));
|
||||
|
||||
// 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 (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
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 data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
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 style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
@@ -360,6 +492,10 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
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}`)} />
|
||||
))}
|
||||
</div>
|
||||
@@ -371,10 +507,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
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 ?? rawLabel;
|
||||
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
|
||||
// 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 (
|
||||
<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-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 className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<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 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 className="dc-btns">
|
||||
<button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
|
||||
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>
|
||||
<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"
|
||||
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>}
|
||||
@@ -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 goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
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(() => {
|
||||
@@ -548,7 +715,7 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
{ddOpen && (
|
||||
<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 }}>
|
||||
{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}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
|
||||
375
CLAUDE_DESIGN/table-cards-densities.jsx
Normal file
375
CLAUDE_DESIGN/table-cards-densities.jsx
Normal 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 };
|
||||
167
CLAUDE_DESIGN/tables-app.jsx
Normal file
167
CLAUDE_DESIGN/tables-app.jsx
Normal 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 />);
|
||||
47
CLAUDE_DESIGN/tables-data.jsx
Normal file
47
CLAUDE_DESIGN/tables-data.jsx
Normal 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;
|
||||
BIN
CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png
Normal file
BIN
CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png
Normal file
BIN
CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -26,6 +26,7 @@ from routers import shifts as shifts_router
|
||||
from routers import settings as settings_router
|
||||
from routers import flags as flags_router
|
||||
from routers import messages as messages_router
|
||||
from routers import sse as sse_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -111,10 +112,13 @@ def _run_migrations():
|
||||
name VARCHAR NOT NULL,
|
||||
emoji VARCHAR,
|
||||
color VARCHAR DEFAULT '#6b7280',
|
||||
text_color VARCHAR DEFAULT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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'",
|
||||
# Compact (half-width) display flag for quick options
|
||||
"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:
|
||||
try:
|
||||
@@ -193,6 +212,9 @@ def _run_migrations():
|
||||
|
||||
@asynccontextmanager
|
||||
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)
|
||||
_run_migrations()
|
||||
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(flags_router.router, prefix="/api/flags", tags=["flags"])
|
||||
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
||||
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||
|
||||
@@ -15,7 +15,8 @@ class TableFlagDef(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
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)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
@@ -93,13 +93,17 @@ class OrderAuditLog(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), 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)
|
||||
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
|
||||
payment_method = Column(String, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
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")
|
||||
waiter = relationship("User")
|
||||
|
||||
137
local_backend/print_size_test.py
Normal file
137
local_backend/print_size_test.py
Normal 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.")
|
||||
@@ -7,6 +7,7 @@ from models.flag import TableFlagDef, TableFlagAssignment
|
||||
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -124,9 +125,11 @@ def set_table_flags(
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return db.query(TableFlagAssignment).filter(
|
||||
result = db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).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)
|
||||
@@ -139,3 +142,4 @@ def clear_table_flags(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})
|
||||
|
||||
@@ -11,6 +11,7 @@ from schemas.message import (
|
||||
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -113,7 +114,22 @@ def send_message(
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
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])
|
||||
|
||||
@@ -9,7 +9,7 @@ from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import Table
|
||||
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
|
||||
|
||||
class PrintOrderRequest(BaseModel):
|
||||
@@ -33,6 +33,7 @@ class MoveItemsRequest(BaseModel):
|
||||
|
||||
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.sse_bus import broadcast_sync
|
||||
|
||||
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)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
|
||||
return order
|
||||
|
||||
|
||||
@@ -209,7 +211,7 @@ def add_items(
|
||||
db.refresh(order)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
amount=total_paid, payment_method=body.payment_method)
|
||||
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}
|
||||
|
||||
|
||||
@@ -312,9 +315,105 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
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)
|
||||
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()
|
||||
@@ -325,6 +424,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
|
||||
|
||||
@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}")
|
||||
db.commit()
|
||||
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
|
||||
|
||||
|
||||
@@ -517,6 +618,8 @@ def merge_order(
|
||||
|
||||
db.commit()
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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.",
|
||||
"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.",
|
||||
# Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1
|
||||
"print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')",
|
||||
"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 layout
|
||||
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||
"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 = {
|
||||
@@ -33,12 +39,17 @@ DEFAULTS = {
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": "",
|
||||
"dev.spoof_printing": "false",
|
||||
"print.font_item_name": "16:0", # double-height, no bold
|
||||
"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.ticket_mode": "detailed",
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
60
local_backend/routers/sse.py
Normal file
60
local_backend/routers/sse.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@@ -61,6 +61,15 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -12,6 +12,7 @@ from schemas.table import (
|
||||
TableBatchCreate,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -105,6 +106,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
|
||||
return table
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ class FlagDefCreate(BaseModel):
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = "#6b7280"
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
@@ -14,6 +15,7 @@ class FlagDefUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@@ -23,6 +25,7 @@ class FlagDefOut(BaseModel):
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ class SelectedOptionInput(BaseModel):
|
||||
name: Optional[str] = None
|
||||
price_delta: 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):
|
||||
@@ -73,6 +76,13 @@ class PayItemsRequest(BaseModel):
|
||||
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):
|
||||
waiter_id: int
|
||||
|
||||
@@ -93,6 +103,8 @@ class AuditLogOut(BaseModel):
|
||||
payment_method: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
created_at: UTCDatetime
|
||||
offline_at: Optional[str] = None
|
||||
is_duplicate: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -54,13 +54,32 @@ _DIVIDER_CHARS = {
|
||||
"empty": "",
|
||||
}
|
||||
|
||||
_PRINT_FONT_DEFAULTS = {
|
||||
"print.font_item_name": "16:0",
|
||||
"print.font_options": "0:0",
|
||||
"print.font_table": "16:0",
|
||||
"print.font_order_number": "48:1",
|
||||
"print.font_header": "48:1",
|
||||
_PRINT_SETTING_KEYS = [
|
||||
"print.ticket_mode",
|
||||
"print.divider_style",
|
||||
"print.font_order_number",
|
||||
"print.font_meta",
|
||||
"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.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):
|
||||
@@ -68,27 +87,28 @@ _PRINT_FONT_DEFAULTS = {
|
||||
# 16 = double-height (bit4)
|
||||
# 32 = double-width (bit5)
|
||||
# 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]:
|
||||
"""Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag)."""
|
||||
def _decode_font(value: str) -> tuple[int, bool, bool]:
|
||||
"""Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag)."""
|
||||
try:
|
||||
parts = str(value).split(":")
|
||||
size = int(parts[0])
|
||||
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):
|
||||
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(
|
||||
PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys())
|
||||
PosSettings.key.in_(_PRINT_SETTING_KEYS)
|
||||
).all()
|
||||
fonts = dict(_PRINT_FONT_DEFAULTS)
|
||||
settings = dict(_PRINT_SETTING_DEFAULTS)
|
||||
for row in rows:
|
||||
fonts[row.key] = row.value
|
||||
return fonts
|
||||
settings[row.key] = row.value
|
||||
return settings
|
||||
|
||||
|
||||
def _divider(p: Network, style: str = "dash"):
|
||||
@@ -100,14 +120,42 @@ def _divider(p: Network, style: str = "dash"):
|
||||
p._raw(b'\n')
|
||||
|
||||
|
||||
def _item_line(name: str, qty: int) -> str:
|
||||
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
|
||||
qty_str = str(qty)
|
||||
gap = LINE_WIDTH - len(name) - len(qty_str)
|
||||
if gap < 3:
|
||||
return f"{name} {qty_str}"
|
||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||
return f"{name}{dots}{qty_str}"
|
||||
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
||||
"""Build a dot-leader line ending with 'xN'.
|
||||
line_width must reflect the effective width at the chosen font size
|
||||
(double-width fonts halve the available char count to 24)."""
|
||||
suffix = f"x{qty}"
|
||||
available = line_width - len(name) - len(suffix)
|
||||
if available < 2:
|
||||
# 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:
|
||||
@@ -152,88 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
def _font(p: Network, byte_val: int, bold: bool = False):
|
||||
p._raw(bytes([0x1b, 0x21, byte_val]))
|
||||
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||
def _parse_options(item: OrderItem) -> dict:
|
||||
"""
|
||||
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):
|
||||
fonts = _load_print_fonts(db)
|
||||
div = fonts["print.divider_style"]
|
||||
cfg = _load_print_settings(db)
|
||||
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_table, bold_table = _decode_font(fonts["print.font_table"])
|
||||
sz_item, bold_item = _decode_font(fonts["print.font_item_name"])
|
||||
sz_opt, bold_opt = _decode_font(fonts["print.font_options"])
|
||||
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
|
||||
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
|
||||
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
|
||||
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
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
_font(p, sz_order, bold_order)
|
||||
_raw_text(p, f"Παραγγελια #{order.id}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
_divider(p, div)
|
||||
# Resolve display names
|
||||
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
|
||||
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
|
||||
now_str = _greek_date(datetime.datetime.now())
|
||||
|
||||
# Meta — table / waiter / time
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_font(p, sz_table, bold_table)
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
_raw_text(p, f"Date: {now}\n")
|
||||
_raw_text(p, f"Table: {order.table_id}\n")
|
||||
_raw_text(p, f"Waiter: {order.opened_by}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
_divider(p, div)
|
||||
# ── COMPACT header — single line ────────────────────────────────────────
|
||||
if compact:
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_apply_font(p, sz_ord, b_ord)
|
||||
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
||||
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
||||
_reset_font(p)
|
||||
_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:
|
||||
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)
|
||||
_raw_text(p, _item_line(name, item.quantity) + "\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_apply_font(p, sz_item, b_item)
|
||||
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
||||
_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:
|
||||
try:
|
||||
removed_ids = json.loads(item.removed_ingredients)
|
||||
if removed_ids:
|
||||
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if item.selected_options:
|
||||
try:
|
||||
option_ids = json.loads(item.selected_options)
|
||||
if option_ids:
|
||||
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
|
||||
removed = json.loads(item.removed_ingredients)
|
||||
if removed:
|
||||
names = [n.upper() if c_ing else n for n in removed]
|
||||
joined = " · ".join(names)
|
||||
_apply_font(p, sz_ing, b_ing)
|
||||
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
|
||||
_reset_font(p)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Per-item note
|
||||
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')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
# Blank line between items in detailed mode
|
||||
if not compact:
|
||||
p._raw(b'\n')
|
||||
|
||||
_divider(p, div)
|
||||
|
||||
# Order-level notes
|
||||
if order.notes:
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "Σημειωσεις:\n")
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"{order.notes}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
note_text = order.notes.upper() if c_onote else order.notes
|
||||
_apply_font(p, sz_onote, b_onote)
|
||||
_raw_text(p, f"Σημ: {note_text}\n")
|
||||
_reset_font(p)
|
||||
if not compact:
|
||||
_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.cut()
|
||||
|
||||
|
||||
84
local_backend/services/sse_bus.py
Normal file
84
local_backend/services/sse_bus.py
Normal 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
|
||||
@@ -49,6 +49,7 @@ const EVENT_LABELS = {
|
||||
ORDER_OPENED: 'Άνοιγμα',
|
||||
ITEMS_ADDED: 'Προσθήκη',
|
||||
PAYMENT: 'Πληρωμή',
|
||||
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
|
||||
ORDER_CLOSED: 'Κλείσιμο',
|
||||
ORDER_CANCELLED: 'Ακύρωση',
|
||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||
@@ -60,30 +61,47 @@ function AuditTab({ order, waiterMap }) {
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
||||
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}
|
||||
</span>
|
||||
{order.audit_logs.map(log => {
|
||||
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
|
||||
const badgeClass = isDuplicate
|
||||
? 'bg-red-100 text-red-700'
|
||||
: isPayment ? 'bg-green-100 text-green-700'
|
||||
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
|
||||
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
// Show offline_at (real payment time) when available, else server created_at
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ function FlagDefsSection() {
|
||||
const qc = useQueryClient()
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
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 { data: flags = [], isLoading } = useQuery({
|
||||
queryKey: ['flag-defs'],
|
||||
@@ -279,7 +279,7 @@ function FlagDefsSection() {
|
||||
})
|
||||
const createMut = useMutation({
|
||||
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('Σφάλμα'),
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
@@ -294,7 +294,7 @@ function FlagDefsSection() {
|
||||
})
|
||||
function startEdit(flag) {
|
||||
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' }
|
||||
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' }} />
|
||||
))}
|
||||
</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}
|
||||
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>
|
||||
@@ -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' }} />
|
||||
))}
|
||||
</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}
|
||||
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)}
|
||||
|
||||
@@ -4,11 +4,9 @@ import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
// ── Font option definitions ────────────────────────────────────────────────
|
||||
// Value encodes: "ESC_BANG_BYTE:BOLD" where BOLD is 0 or 1
|
||||
// ESC ! correct bit map for TP850UE:
|
||||
// bit3 (0x08) = bold
|
||||
// bit4 (0x10) = double-height
|
||||
// bit5 (0x20) = double-width
|
||||
// Value encodes: "SIZE:BOLD:CAPS"
|
||||
// SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
|
||||
// BOLD: 0|1 CAPS: 0|1
|
||||
const FONT_SIZE_OPTIONS = [
|
||||
{ size: '0', label: 'Μικρά' },
|
||||
{ size: '16', label: 'Ψηλά' },
|
||||
@@ -16,12 +14,13 @@ const FONT_SIZE_OPTIONS = [
|
||||
{ size: '48', label: 'Ψηλά και Πλατιά' },
|
||||
]
|
||||
|
||||
// We store the value as "SIZE:BOLD" e.g. "16:1" or "0:0"
|
||||
function encodeFont(size, bold) { return `${size}:${bold ? '1' : '0'}` }
|
||||
function encodeFont(size, bold, caps) {
|
||||
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
|
||||
}
|
||||
function decodeFont(val) {
|
||||
if (!val) return { size: '0', bold: false }
|
||||
const [size, bold] = val.split(':')
|
||||
return { size: size ?? '0', bold: bold === '1' }
|
||||
if (!val) return { size: '0', bold: false, caps: false }
|
||||
const [size, bold, caps] = val.split(':')
|
||||
return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
|
||||
}
|
||||
|
||||
const DIVIDER_OPTIONS = [
|
||||
@@ -31,26 +30,21 @@ const DIVIDER_OPTIONS = [
|
||||
{ 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 = {
|
||||
'print.font_item_name': '16:0',
|
||||
'print.font_options': '0:0',
|
||||
'print.font_table': '16:0',
|
||||
'print.font_order_number': '48:1',
|
||||
'print.font_header': '48:1',
|
||||
'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',
|
||||
'print.divider_style': 'dash',
|
||||
'print.ticket_mode': 'detailed',
|
||||
}
|
||||
|
||||
// ── Preview box ────────────────────────────────────────────────────────────
|
||||
// Fixed height tall enough for the largest option (Ψηλά και Πλατιά).
|
||||
// All rows share the same height so columns stay aligned.
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
const PREVIEW_W = 200
|
||||
const PREVIEW_H = 50
|
||||
|
||||
@@ -61,7 +55,7 @@ const sizeStyle = {
|
||||
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
|
||||
}
|
||||
|
||||
function FontPreview({ size, bold }) {
|
||||
function FontPreview({ size, bold, caps }) {
|
||||
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||
return (
|
||||
<div style={{
|
||||
@@ -80,30 +74,66 @@ function FontPreview({ size, bold }) {
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
SAMPLE
|
||||
{caps ? 'SAMPLE' : 'Sample'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single font row ────────────────────────────────────────────────────────
|
||||
function FontRow({ field, value, onChange, isPending }) {
|
||||
const { size, bold } = decodeFont(value)
|
||||
// ── Toggle button (shared) ─────────────────────────────────────────────────
|
||||
function ToggleBtn({ active, onClick, disabled, label }) {
|
||||
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)) }
|
||||
function handleBold() { onChange(field.key, encodeFont(size, !bold)) }
|
||||
// ── Single font row ────────────────────────────────────────────────────────
|
||||
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 (
|
||||
<div style={{
|
||||
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 */}
|
||||
<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}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||
{field.sub && (
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size dropdown */}
|
||||
@@ -123,31 +153,28 @@ function FontRow({ field, value, onChange, isPending }) {
|
||||
</select>
|
||||
|
||||
{/* Bold toggle */}
|
||||
<button
|
||||
onClick={handleBold}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
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>
|
||||
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||
|
||||
{/* Caps toggle */}
|
||||
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -181,10 +208,10 @@ function DividerRow({ value, onChange, isPending }) {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* spacer to align with bold button column */}
|
||||
<div style={{ width: 87, flexShrink: 0 }} />
|
||||
{/* spacer to align with bold+caps column */}
|
||||
<div style={{ width: 194, flexShrink: 0 }} />
|
||||
|
||||
{/* Preview — same fixed size as font previews */}
|
||||
{/* Preview */}
|
||||
<div style={{
|
||||
background: '#1a1a1a', borderRadius: 8,
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||
|
||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
|
||||
|
||||
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,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Enable/disable toggle */}
|
||||
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
|
||||
style={{
|
||||
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>
|
||||
|
||||
{/* Name + connection info */}
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Reachability badge */}
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
|
||||
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
|
||||
@@ -315,7 +456,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<button onClick={() => onTest(printer.id)} disabled={testPending}
|
||||
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
export default function PrintFontsTab() {
|
||||
const qc = useQueryClient()
|
||||
@@ -432,6 +605,12 @@ export default function PrintFontsTab() {
|
||||
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({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
|
||||
@@ -448,28 +627,44 @@ export default function PrintFontsTab() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* 1. Printers */}
|
||||
<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 style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση.
|
||||
Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
|
||||
</p>
|
||||
</div>
|
||||
{FONT_FIELDS.map(field => (
|
||||
<FontRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={val(field.key)}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
/>
|
||||
|
||||
{FONT_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<SubgroupHeader label={group.group} />
|
||||
{group.fields.map((field, idx) => (
|
||||
<FontRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={val(field.key)}
|
||||
onChange={handleChange}
|
||||
isPending={updateMut.isPending}
|
||||
nested={group.fields.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider style card */}
|
||||
{/* 4. Divider style */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||
|
||||
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.8icf0qrbd5"
|
||||
"revision": "0.jqv9du572qo"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
7
waiter_pwa/package-lock.json
generated
7
waiter_pwa/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
@@ -2940,6 +2941,12 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
|
||||
@@ -4,13 +4,17 @@ import useAuthStore from './store/authStore'
|
||||
import useShiftStore from './store/shiftStore'
|
||||
import useThemeStore from './store/themeStore'
|
||||
import useTableColourStore from './store/tableColourStore'
|
||||
import useConnectionStore from './store/connectionStore'
|
||||
import client from './api/client'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TableListPage from './pages/TableListPage'
|
||||
import TableDetailPage from './pages/TableDetailPage'
|
||||
import AddItemsPage from './pages/AddItemsPage'
|
||||
import OfflinePage from './pages/OfflinePage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import { NotificationProvider } from './context/NotificationContext'
|
||||
import { SSEProvider } from './context/SSEContext'
|
||||
import ConnectionLostModal from './components/ConnectionLostModal'
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -269,11 +273,18 @@ function AuthRehydrator() {
|
||||
|
||||
function OfflineListener() {
|
||||
const navigate = useNavigate()
|
||||
const { token } = useAuthStore()
|
||||
const { status } = useConnectionStore()
|
||||
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)
|
||||
return () => window.removeEventListener('backend-offline', handler)
|
||||
}, [navigate])
|
||||
}, [navigate, token, status])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -307,18 +318,22 @@ export default function App() {
|
||||
<ColourLoader />
|
||||
<AuthRehydrator />
|
||||
<OfflineListener />
|
||||
<NotificationProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||
</Routes>
|
||||
</NotificationProvider>
|
||||
<SSEProvider>
|
||||
<NotificationProvider>
|
||||
<ConnectionLostModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||
</Routes>
|
||||
</NotificationProvider>
|
||||
</SSEProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
100
waiter_pwa/src/components/ConnectionLostModal.jsx
Normal file
100
waiter_pwa/src/components/ConnectionLostModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
waiter_pwa/src/components/EmergencyBar.jsx
Normal file
40
waiter_pwa/src/components/EmergencyBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -147,20 +147,20 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
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
|
||||
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) {
|
||||
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
|
||||
})
|
||||
|
||||
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]
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -715,9 +715,9 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
||||
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
|
||||
if (isFullyDefault) return []
|
||||
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.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, type: 'pref_sub' })
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||
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 entries = []
|
||||
for (let i = 0; i < sel.qty; i++) {
|
||||
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
|
||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.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, type: 'extra_sub' })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
@@ -736,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
||||
const quickEntries = quickOptions.flatMap(opt => {
|
||||
const q = quickState[opt.id] || 0
|
||||
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)
|
||||
|
||||
@@ -73,12 +73,11 @@ function buildSections(parent, subcategories, directProducts) {
|
||||
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 initialCatId = topLevel[0]?.id ?? null
|
||||
const [activeCat, setActiveCat] = useState(initialCatId)
|
||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
|
||||
const [expandedSubs, setExpandedSubs] = useState(() => {
|
||||
if (!initialCatId) return {}
|
||||
@@ -125,18 +124,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<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__fade" />
|
||||
<div className="category-tabs__scroll">
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useRef, useState } from 'react'
|
||||
import useThemeStore from '../store/themeStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const STATUS_LABELS = {
|
||||
free: 'ΕΛΕΥΘΕΡΟ',
|
||||
open: 'ΑΝΟΙΧΤΟ',
|
||||
@@ -13,7 +15,555 @@ const STATUS_LABELS = {
|
||||
const DRAG_THRESHOLD = 8
|
||||
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 startPos = useRef({ x: 0, y: 0 })
|
||||
const didFire = useRef(false)
|
||||
@@ -31,8 +581,6 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
||||
const mode = dark ? 'dark' : 'light'
|
||||
const cfg = colours[mode][statusKey]
|
||||
|
||||
const displayName = table.label || `T${table.number}`
|
||||
|
||||
function cancel() {
|
||||
clearTimeout(holdTimer.current)
|
||||
holdTimer.current = null
|
||||
@@ -57,10 +605,7 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
cancel()
|
||||
setShowTip(false)
|
||||
}
|
||||
function onTouchEnd() { cancel(); setShowTip(false) }
|
||||
|
||||
function onMouseDown(e) {
|
||||
startPos.current = { x: e.clientX, y: e.clientY }
|
||||
@@ -85,11 +630,21 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
||||
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 (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||||
<button
|
||||
className="table-card-v2"
|
||||
style={{ background: cfg.cardBg }}
|
||||
style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||
onClick={handleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
@@ -99,89 +654,16 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Top-left: table name + area */}
|
||||
<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>
|
||||
)}
|
||||
<CardComponent {...cardProps} />
|
||||
</button>
|
||||
|
||||
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
|
||||
{showTip && flags.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
||||
boxShadow: '0 4px 16px var(--shadow)',
|
||||
minWidth: 160,
|
||||
pointerEvents: 'none',
|
||||
minWidth: 160, pointerEvents: 'none',
|
||||
}}>
|
||||
{flags.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||
|
||||
@@ -168,6 +168,12 @@ export default function UserMenu() {
|
||||
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
|
||||
</button>
|
||||
|
||||
{/* ── Settings ──────────────────────────────────────── */}
|
||||
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
|
||||
<span className="user-menu-item__icon">⚙️</span>
|
||||
<span>Ρυθμίσεις</span>
|
||||
</button>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
|
||||
|
||||
@@ -52,9 +52,8 @@ function NotificationBanner({ message, onAck }) {
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { token, user } = useAuthStore()
|
||||
const [pendingMessages, setPendingMessages] = useState([]) // unacked
|
||||
const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history)
|
||||
const pollRef = useRef(null)
|
||||
const [pendingMessages, setPendingMessages] = useState([])
|
||||
const [recentMessages, setRecentMessages] = useState([])
|
||||
|
||||
const fetchUnread = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
@@ -72,14 +71,62 @@ export function NotificationProvider({ children }) {
|
||||
} catch { }
|
||||
}, [token, user?.id])
|
||||
|
||||
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
|
||||
useEffect(() => {
|
||||
if (!token || !user) return
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
pollRef.current = setInterval(fetchUnread, 2000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
const id = setInterval(fetchUnread, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [token, user?.id])
|
||||
|
||||
// SSE message_sent events → add to pending without polling
|
||||
useEffect(() => {
|
||||
function onSSEEvent(e) {
|
||||
const { type, data } = e.detail
|
||||
if (type !== 'message_sent') return
|
||||
if (!user) return
|
||||
|
||||
// Check if this message targets us (empty = broadcast)
|
||||
const targets = data.target_waiter_ids || []
|
||||
if (targets.length > 0 && !targets.includes(user.id)) return
|
||||
|
||||
const msg = {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
sender_name: data.sender_name,
|
||||
body: data.body,
|
||||
table_ids: data.table_ids,
|
||||
created_at: data.created_at,
|
||||
acked_by: [],
|
||||
}
|
||||
|
||||
setPendingMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev]
|
||||
})
|
||||
setRecentMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev].slice(0, 10)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('sse-event', onSSEEvent)
|
||||
return () => window.removeEventListener('sse-event', onSSEEvent)
|
||||
}, [user?.id])
|
||||
|
||||
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
|
||||
useEffect(() => {
|
||||
function onSSEConnect() {
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
}
|
||||
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
|
||||
// through the backend-coming-back-online signal that SSEProvider dispatches
|
||||
window.addEventListener('sse-reconnected', onSSEConnect)
|
||||
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
|
||||
}, [fetchUnread, fetchRecent])
|
||||
|
||||
async function ackMessage(messageId) {
|
||||
try {
|
||||
await client.post(`/api/messages/${messageId}/ack`)
|
||||
@@ -91,7 +138,7 @@ export function NotificationProvider({ children }) {
|
||||
const unreadCount = pendingMessages.length
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}>
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
|
||||
{children}
|
||||
|
||||
{/* Floating banner stack (max 3 visible) */}
|
||||
|
||||
189
waiter_pwa/src/context/SSEContext.jsx
Normal file
189
waiter_pwa/src/context/SSEContext.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
waiter_pwa/src/db/posdb.js
Normal file
15
waiter_pwa/src/db/posdb.js
Normal 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
|
||||
94
waiter_pwa/src/hooks/useSSE.js
Normal file
94
waiter_pwa/src/hooks/useSSE.js
Normal 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 }
|
||||
}
|
||||
@@ -211,70 +211,23 @@ html, body {
|
||||
.text-input:focus { border-color: var(--accent); }
|
||||
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
|
||||
|
||||
/* ── Filter Tabs ─────────────────────────────────────────── */
|
||||
.filter-tabs {
|
||||
/* ── Zone Tab Bar (replaces old filter-tabs) ─────────────── */
|
||||
.zone-tab-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.filter-tab {
|
||||
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); }
|
||||
.zone-tab-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Table Grid ──────────────────────────────────────────── */
|
||||
.table-grid {
|
||||
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 Grid — density-driven via inline style ─────────── */
|
||||
/* Cards use inline styles per density, grid columns come from JS */
|
||||
.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 {
|
||||
position: absolute;
|
||||
@@ -315,20 +268,10 @@ html, body {
|
||||
align-items: stretch;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 10px 36px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -20,6 +20,10 @@ export default function AddItemsPage() {
|
||||
const [printAck, setPrintAck] = useState(null)
|
||||
const [cartOpen, setCartOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -310,31 +314,55 @@ export default function AddItemsPage() {
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={handleBack}>←</button>
|
||||
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
||||
{/* Cart icon with badge — opens side drawer */}
|
||||
<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 style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{/* Search button */}
|
||||
<button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<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>
|
||||
</button>
|
||||
{/* Categories button */}
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Cart button with badge */}
|
||||
<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>
|
||||
|
||||
{/* Product picker takes all remaining space */}
|
||||
{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 ΑΠΟΣΤΟΛΗ ─────────────── */}
|
||||
@@ -382,17 +410,12 @@ export default function AddItemsPage() {
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending}
|
||||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||||
</button>
|
||||
|
||||
{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>
|
||||
|
||||
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
||||
@@ -465,7 +488,7 @@ export default function AddItemsPage() {
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%' }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending}
|
||||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
||||
</button>
|
||||
@@ -483,6 +506,46 @@ export default function AddItemsPage() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -638,3 +701,144 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
|
||||
</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() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,14 +81,19 @@ export default function LoginPage() {
|
||||
|
||||
const [waiters, setWaiters] = useState([])
|
||||
const [loadingWaiters, setLoadingWaiters] = useState(true)
|
||||
const [serverUnreachable, setServerUnreachable] = useState(false)
|
||||
const [selectedWaiter, setSelectedWaiter] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/auth/waiters')
|
||||
.then(r => setWaiters(r.data))
|
||||
.catch(() => setWaiters([]))
|
||||
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
|
||||
.catch(err => {
|
||||
// No response = network error = server unreachable
|
||||
if (!err.response) setServerUnreachable(true)
|
||||
setWaiters([])
|
||||
})
|
||||
.finally(() => setLoadingWaiters(false))
|
||||
}, [])
|
||||
|
||||
@@ -130,6 +135,30 @@ export default function LoginPage() {
|
||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||
{loadingWaiters ? (
|
||||
<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 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
|
||||
) : (
|
||||
|
||||
345
waiter_pwa/src/pages/SettingsPage.jsx
Normal file
345
waiter_pwa/src/pages/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import TableCard from '../components/TableCard'
|
||||
import ConnectionBanner from '../components/ConnectionBanner'
|
||||
import EmergencyBar from '../components/EmergencyBar'
|
||||
import UserMenu from '../components/UserMenu'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
import { queueOfflinePayment } from '../services/offlinePayments'
|
||||
import { useNotifications } from '../context/NotificationContext'
|
||||
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) + ' €' }
|
||||
|
||||
// ─── 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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<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>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{msg.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
|
||||
{msg.sender_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||
{tableIds.length > 0 && (
|
||||
@@ -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 = [
|
||||
{ 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 statusLabel = {
|
||||
open: 'Ανοιχτό',
|
||||
partially_paid: 'Μερικώς πληρωμένο',
|
||||
paid: 'Πληρωμένο',
|
||||
open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
|
||||
}[order?.status] || 'Ελεύθερο'
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
{/* Status overview card */}
|
||||
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
|
||||
padding: '16px 20px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="modal-handle" style={{ 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: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{order ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{flags.map(f => (
|
||||
@@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Quick actions card */}
|
||||
<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={{ 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 }}>
|
||||
{QUICK_ACTIONS.map((a, i) => {
|
||||
const disabled = !order && a.key !== 'flags'
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
disabled={disabled}
|
||||
onClick={() => { onClose(); onAction(a.key) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 0', background: 'none', border: 'none',
|
||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
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,
|
||||
}}>
|
||||
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 0', background: 'none', border: 'none',
|
||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
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" />
|
||||
</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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TableListPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { status: connStatus } = useConnectionStore()
|
||||
const isEmergency = connStatus === 'emergency'
|
||||
|
||||
const [tables, setTables] = useState([])
|
||||
const [groups, setGroups] = useState([])
|
||||
const [orders, setOrders] = useState([])
|
||||
const [flagDefs, setFlagDefs] = 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 [zoneOpen, setZoneOpen] = useState(false)
|
||||
const [selectedZones, setSelectedZones] = useState(new Set())
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
|
||||
const zoneRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [quickModal, setQuickModal] = useState(null)
|
||||
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 {
|
||||
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(() => {
|
||||
const handler = () => setOffline(true)
|
||||
window.addEventListener('backend-offline', handler)
|
||||
@@ -215,28 +392,37 @@ export default function TableListPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onClick(e) {
|
||||
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
const handler = () => load()
|
||||
window.addEventListener('sse-reconnected', handler)
|
||||
return () => window.removeEventListener('sse-reconnected', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus])
|
||||
|
||||
async function load() {
|
||||
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/orders/active'),
|
||||
client.get('/api/tables/groups'),
|
||||
client.get('/api/flags/defs'),
|
||||
client.get('/api/flags/assignments'),
|
||||
client.get('/api/settings/'),
|
||||
client.get('/api/waiters/on-shift'),
|
||||
])
|
||||
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)
|
||||
setFlagDefs(flagDefsRes.data)
|
||||
setFlagAssignments(flagAssignRes.data)
|
||||
setWaiters(waitersRes.data)
|
||||
const raw = settingsRes.data?.['ui.table_colours']?.value
|
||||
if (raw) loadFromBackend(raw)
|
||||
setOffline(false)
|
||||
@@ -245,6 +431,48 @@ export default function TableListPage() {
|
||||
|
||||
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 tableFlagsMap = {}
|
||||
flagAssignments.forEach(a => {
|
||||
@@ -252,36 +480,88 @@ export default function TableListPage() {
|
||||
const def = flagDefMap[a.flag_id]
|
||||
if (def) tableFlagsMap[a.table_id].push(def)
|
||||
})
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w]))
|
||||
|
||||
function getOrder(tableId) {
|
||||
return orders.find(o => o.table_id === tableId)
|
||||
function getOrder(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) {
|
||||
if (!order || !user) return false
|
||||
return order.waiter_ids?.includes(user.id)
|
||||
}
|
||||
// ── Filtering logic ────────────────────────────────────────────────────────
|
||||
// Zones visible in top bar = those allowed by zoneFilter (or all if empty)
|
||||
const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
|
||||
|
||||
function toggleZone(id) {
|
||||
setSelectedZones(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
// visibleGroups = groups shown in the top bar
|
||||
const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id))
|
||||
|
||||
// Validate activeZoneTab against current allowedZoneIds
|
||||
// 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 order = getOrder(t.id)
|
||||
if (filter === 'free' && order) return false
|
||||
if (filter === 'mine' && !isMyOrder(order)) return false
|
||||
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
|
||||
|
||||
// Status filter
|
||||
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
|
||||
})
|
||||
|
||||
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) {
|
||||
// Navigate to table then trigger action via URL param so TableDetailPage can handle it
|
||||
navigate(`/tables/${tableId}?action=${actionKey}`)
|
||||
}
|
||||
|
||||
@@ -299,15 +579,14 @@ export default function TableListPage() {
|
||||
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">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{(unreadCount || 0) > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
@@ -319,109 +598,135 @@ export default function TableListPage() {
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{offline && <ConnectionBanner />}
|
||||
{isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
|
||||
|
||||
<div className="filter-tabs">
|
||||
{FILTERS.map(f => (
|
||||
<button key={f} className={`filter-tab ${filter === f ? 'filter-tab--active' : ''}`} onClick={() => setFilter(f)}>
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
{/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
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 style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
|
||||
<div className="table-grid">
|
||||
{/* ── 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 => {
|
||||
const order = getOrder(t.id)
|
||||
const tableFlags = tableFlagsMap[t.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 destination = order
|
||||
? `/tables/${t.id}`
|
||||
: `/tables/${t.id}/add?new=1`
|
||||
const alreadyPaidLocally = order && localPaidOrderIds.has(order.id)
|
||||
const orderWaiters = getOrderWaiters(order)
|
||||
|
||||
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 (
|
||||
<TableCard
|
||||
key={t.id}
|
||||
table={t}
|
||||
order={order}
|
||||
order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
|
||||
isMine={isMyOrder(order)}
|
||||
flags={tableFlags}
|
||||
groupName={grp?.name || ''}
|
||||
onClick={() => navigate(destination)}
|
||||
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
waiterObjects={orderWaiters}
|
||||
density={density}
|
||||
onClick={handleClick}
|
||||
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="fab" onClick={load} title="Ανανέωση">↺</button>
|
||||
</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 && (
|
||||
<NotificationDrawer
|
||||
messages={recentMessages || []}
|
||||
onClose={() => setShowNotifs(false)}
|
||||
onAck={ackMessage}
|
||||
/>
|
||||
<NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
|
||||
)}
|
||||
|
||||
{quickModal && (
|
||||
@@ -434,6 +739,43 @@ export default function TableListPage() {
|
||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{emergencyPayModal && (
|
||||
<EmergencyPayModal
|
||||
table={emergencyPayModal.table}
|
||||
order={emergencyPayModal.order}
|
||||
onClose={() => setEmergencyPayModal(null)}
|
||||
onPay={handleEmergencyPay}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
61
waiter_pwa/src/services/offlinePayments.js
Normal file
61
waiter_pwa/src/services/offlinePayments.js
Normal 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
|
||||
}
|
||||
33
waiter_pwa/src/store/connectionStore.js
Normal file
33
waiter_pwa/src/store/connectionStore.js
Normal 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
|
||||
39
waiter_pwa/src/store/tableViewStore.js
Normal file
39
waiter_pwa/src/store/tableViewStore.js
Normal 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
|
||||
@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: 'all',
|
||||
allowedHosts: ['all','pos-waiter.bonamin.gr'],
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
Reference in New Issue
Block a user