Compare commits
7 Commits
bb39088464
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d2dd9f1c6 | |||
| b311f4c5b6 | |||
| a3aa71348e | |||
| c9ad78ec71 | |||
| 8e27b7666e | |||
| 1fd7d16ec9 | |||
| 240abb2e73 |
@@ -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
|
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||||
// State persists to a .design-canvas.state.json sidecar via the host
|
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||||
// bridge. No assets, no deps.
|
// via the host bridge. No assets, no deps.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// <DesignCanvas>
|
// <DesignCanvas>
|
||||||
@@ -39,17 +39,58 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
|||||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||||
'.dc-card *{scrollbar-width:none}',
|
'.dc-card *{scrollbar-width:none}',
|
||||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
// right. Single flex row; when the artboard's on-screen width is too
|
||||||
|
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||||
|
// ~4ch via the container query) and the buttons stay on the row.
|
||||||
|
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||||
|
' display:flex;align-items:center;container-type:inline-size}',
|
||||||
|
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||||
|
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||||
'.dc-grip:active{cursor:grabbing}',
|
'.dc-grip:active{cursor:grabbing}',
|
||||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||||
|
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||||
|
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||||
|
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||||
|
// until the card is moused.
|
||||||
|
'@container (max-width: 110px){',
|
||||||
|
' .dc-labeltext{display:none}',
|
||||||
|
' .dc-grip{opacity:0}',
|
||||||
|
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||||
|
'}',
|
||||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||||
|
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}',
|
||||||
|
'.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||||
|
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||||
|
' font:inherit;transition:background .12s,color .12s}',
|
||||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
'.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}',
|
||||||
|
'.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;',
|
||||||
|
' font-size:12px;font-weight:500}',
|
||||||
|
'.dc-delete.dc-confirm:hover{background:#b5563a}',
|
||||||
|
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||||
|
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||||
|
// DCViewport on every transform update and inherits to all descendants —
|
||||||
|
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||||
|
// it the same way.
|
||||||
|
//
|
||||||
|
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||||
|
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||||
|
// after counter-scaling its on-screen width exactly matches the card's —
|
||||||
|
// that's what lets the container query + text-overflow behave against the
|
||||||
|
// card's visible edge at every zoom level.
|
||||||
|
//
|
||||||
|
// The section head uses CSS zoom instead of transform so its layout box
|
||||||
|
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||||
|
// constant-screen-size title would overflow into the (shrinking) world-
|
||||||
|
// space gap and overlap the artboard headers at low zoom.
|
||||||
|
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||||
|
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||||
|
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
}
|
}
|
||||||
@@ -58,8 +99,9 @@ const DCCtx = React.createContext(null);
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||||
|
// .design-canvas.state.json
|
||||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||||
@@ -115,11 +157,19 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
|
|||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
sectionOrder.push(sid);
|
sectionOrder.push(sid);
|
||||||
const persisted = state.sections[sid] || {};
|
const persisted = state.sections[sid] || {};
|
||||||
const srcIds = [];
|
const abs = [];
|
||||||
React.Children.forEach(sec.props.children, (ab) => {
|
React.Children.forEach(sec.props.children, (ab) => {
|
||||||
if (!ab || ab.type !== DCArtboard) return;
|
if (!ab || ab.type !== DCArtboard) return;
|
||||||
const aid = ab.props.id ?? ab.props.label;
|
const aid = ab.props.id ?? ab.props.label;
|
||||||
if (!aid) return;
|
if (aid) abs.push([aid, ab]);
|
||||||
|
});
|
||||||
|
// hidden is scoped to one source revision — when the agent regenerates
|
||||||
|
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||||
|
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||||
|
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||||
|
const srcIds = [];
|
||||||
|
abs.forEach(([aid, ab]) => {
|
||||||
|
if (hidden.includes(aid)) return;
|
||||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||||
srcIds.push(aid);
|
srcIds.push(aid);
|
||||||
});
|
});
|
||||||
@@ -183,11 +233,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|||||||
const vpRef = React.useRef(null);
|
const vpRef = React.useRef(null);
|
||||||
const worldRef = React.useRef(null);
|
const worldRef = React.useRef(null);
|
||||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||||
|
// Persist viewport across reloads so the user lands back where they were
|
||||||
|
// after an agent edit or browser refresh. The sandbox origin is already
|
||||||
|
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||||
|
const tfKey = 'dc-viewport:' + location.pathname;
|
||||||
|
const saveT = React.useRef(0);
|
||||||
|
|
||||||
|
const lastPostedScale = React.useRef();
|
||||||
const apply = React.useCallback(() => {
|
const apply = React.useCallback(() => {
|
||||||
const { x, y, scale } = tf.current;
|
const { x, y, scale } = tf.current;
|
||||||
const el = worldRef.current;
|
const el = worldRef.current;
|
||||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
if (!el) return;
|
||||||
|
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||||
|
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||||
|
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||||
|
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||||
|
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||||
|
if (lastPostedScale.current !== scale) {
|
||||||
|
lastPostedScale.current = scale;
|
||||||
|
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||||
|
}
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
saveT.current = setTimeout(() => {
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
}, 200);
|
||||||
|
}, [tfKey]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const flush = () => {
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||||
|
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||||
|
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||||
|
// window doesn't drop the last pan/zoom.
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -272,6 +359,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|||||||
vp.style.cursor = '';
|
vp.style.cursor = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||||
|
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||||
|
const onHostMsg = (e) => {
|
||||||
|
const d = e.data;
|
||||||
|
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||||
|
} else if (d && d.type === '__dc_probe') {
|
||||||
|
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||||
|
// fires on the iframe's native 'load', which for canvases with
|
||||||
|
// images/fonts is after our mount-time announce, so re-announce.
|
||||||
|
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||||
|
// even if it's unchanged — the host just reset dcScale to 1.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onHostMsg);
|
||||||
|
// Announce canvas mode so the host toolbar proxies its % control here
|
||||||
|
// instead of scaling the iframe element (which would just shrink the
|
||||||
|
// viewport window of an infinite canvas). The apply() that follows emits
|
||||||
|
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||||
|
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||||
|
// effect's restore-path apply() may already have posted the restored
|
||||||
|
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
|
||||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||||
@@ -281,6 +398,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|||||||
vp.addEventListener('pointerup', onPointerUp);
|
vp.addEventListener('pointerup', onPointerUp);
|
||||||
vp.addEventListener('pointercancel', onPointerUp);
|
vp.addEventListener('pointercancel', onPointerUp);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('message', onHostMsg);
|
||||||
vp.removeEventListener('wheel', onWheel);
|
vp.removeEventListener('wheel', onWheel);
|
||||||
vp.removeEventListener('gesturestart', onGestureStart);
|
vp.removeEventListener('gesturestart', onGestureStart);
|
||||||
vp.removeEventListener('gesturechange', onGestureChange);
|
vp.removeEventListener('gesturechange', onGestureChange);
|
||||||
@@ -336,8 +454,13 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|||||||
const all = React.Children.toArray(children);
|
const all = React.Children.toArray(children);
|
||||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
|
||||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||||
|
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||||
|
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||||
|
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||||
|
const srcKey = allIds.join('\x1f');
|
||||||
|
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||||
|
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||||
|
|
||||||
const order = React.useMemo(() => {
|
const order = React.useMemo(() => {
|
||||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||||
@@ -346,13 +469,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|||||||
|
|
||||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||||
|
|
||||||
|
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||||
|
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||||
|
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||||
|
// belonging to the section above. paddingBottom below is just enough for
|
||||||
|
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||||
|
// the title sits tight against its own row at every zoom.
|
||||||
return (
|
return (
|
||||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
<div data-dc-section={sid}
|
||||||
<div style={{ padding: '0 60px 56px' }}>
|
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||||
<DCEditable tag="div" value={sec.title ?? title}
|
<div style={{ padding: '0 60px' }}>
|
||||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
<DCEditable tag="div" value={sec.title ?? title}
|
||||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||||
|
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||||
|
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||||
{order.map((k) => (
|
{order.map((k) => (
|
||||||
@@ -360,6 +492,10 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|||||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||||
|
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||||
|
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||||
|
srcKey,
|
||||||
|
}))}
|
||||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -371,10 +507,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|||||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||||
function DCArtboard() { return null; }
|
function DCArtboard() { return null; }
|
||||||
|
|
||||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||||
const id = rawId ?? rawLabel;
|
const id = rawId ?? rawLabel;
|
||||||
const ref = React.useRef(null);
|
const ref = React.useRef(null);
|
||||||
|
const delRef = React.useRef(null);
|
||||||
|
const [confirming, setConfirming] = React.useState(false);
|
||||||
|
|
||||||
|
// Two-click delete: first click arms the button (turns into an inline
|
||||||
|
// "Delete?" pill), second click commits. Any pointerdown outside the
|
||||||
|
// button disarms.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!confirming) return;
|
||||||
|
const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); };
|
||||||
|
document.addEventListener('pointerdown', off, true);
|
||||||
|
return () => document.removeEventListener('pointerdown', off, true);
|
||||||
|
}, [confirming]);
|
||||||
|
|
||||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||||
// their would-be slots in real time via transforms. DOM order only
|
// their would-be slots in real time via transforms. DOM order only
|
||||||
@@ -440,18 +588,32 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
<div className="dc-labelrow">
|
||||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||||
|
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||||
|
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
<div className="dc-btns">
|
||||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
<button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
|
||||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
onClick={() => { if (confirming) onDelete(); else setConfirming(true); }}
|
||||||
|
title={confirming ? 'Click again to delete' : 'Delete'}>
|
||||||
|
{confirming
|
||||||
|
? <>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6"/></svg>
|
||||||
|
Delete?
|
||||||
|
</>
|
||||||
|
: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6M5 5.5v3M7 5.5v3"/></svg>}
|
||||||
|
</button>
|
||||||
|
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
|
||||||
</button>
|
|
||||||
<div className="dc-card"
|
<div className="dc-card"
|
||||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||||
@@ -489,9 +651,14 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
|||||||
|
|
||||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||||
const goSection = (d) => {
|
const goSection = (d) => {
|
||||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
const n = sectionOrder.length;
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||||
|
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||||
|
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -548,7 +715,7 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
|||||||
{ddOpen && (
|
{ddOpen && (
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||||
{sectionOrder.map((sid) => (
|
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||||
|
|||||||
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 |
85
PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md
Normal file
85
PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Printer Beep Strategy
|
||||||
|
|
||||||
|
## How beeping works on the Jolimark TP850UE
|
||||||
|
|
||||||
|
The printer has a built-in buzzer. Three commands are available:
|
||||||
|
|
||||||
|
| Command | Bytes | Parameters | Notes |
|
||||||
|
|---------|-------|------------|-------|
|
||||||
|
| `BEL` | `0x07` | none | Single short beep, ~50ms. Simplest. |
|
||||||
|
| `ESC BEL n1 n2 n3` | `0x1B 0x07 n1 n2 n3` | n1=on-time (×100ms), n2=off-time (×100ms), n3=count | Full control over length, gap, repetitions. |
|
||||||
|
| `GS BEL n1 n2 n3` | `0x1D 0x07 n1 n2 n3` | n1=count, n2=on-time (×100ms), n3=off-time (×100ms) | Same as ESC BEL but parameter order differs. |
|
||||||
|
|
||||||
|
**Confirmed working on our test:** `ESC BEL` with `n1=2, n2=2, n3=1` = one 200ms beep.
|
||||||
|
Pattern beeps also work: `ESC BEL 1 1 3` = three short beeps in quick succession.
|
||||||
|
|
||||||
|
The beep is triggered by sending these bytes **immediately before or after** the print job —
|
||||||
|
it does not need to be part of a complete print page. You can send a beep-only job
|
||||||
|
(connect, send beep bytes, close) without printing anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to add beeps in the system
|
||||||
|
|
||||||
|
### 1. New kitchen ticket arrives (MOST IMPORTANT)
|
||||||
|
**Where:** `printer_service.py` → `_print_kitchen_ticket()`, just before or after the cut command.
|
||||||
|
**Pattern:** 2 short beeps — signals a new order without being annoying.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 1, 2])) # 2× 100ms beeps
|
||||||
|
p.cut()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Re-print of an existing ticket
|
||||||
|
**Where:** Same function, but only 1 beep to distinguish from a new order.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 2, 1])) # 1× 100ms beep
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Urgent / rush order (future feature)
|
||||||
|
**Where:** If we add an "urgent" flag to orders, trigger a longer or triple beep.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 3, 1, 3])) # 3× 300ms beeps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test print
|
||||||
|
**Where:** `send_test_print()` — already sends a test page, add 1 beep so the cook knows
|
||||||
|
to look at the printer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation plan (when ready)
|
||||||
|
|
||||||
|
1. **Add a per-printer setting:** `print.beep_on_ticket` = `true`/`false`
|
||||||
|
(some stations may not want beeping, e.g. a bar printer near customers)
|
||||||
|
|
||||||
|
2. **Add a beep pattern setting:** `print.beep_pattern` = `single` / `double` / `triple`
|
||||||
|
|
||||||
|
3. **In `_print_kitchen_ticket`:** After building the ticket, before `p.cut()`:
|
||||||
|
```python
|
||||||
|
if beep_enabled:
|
||||||
|
p._raw(beep_bytes_for_pattern)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **No separate beep job needed** — bake it into the ticket job. The buzzer fires
|
||||||
|
as the paper is cutting, which is the natural attention signal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings keys to add (future)
|
||||||
|
|
||||||
|
```
|
||||||
|
print.beep_on_ticket "true" / "false" default: "true"
|
||||||
|
print.beep_pattern "single" / "double" / "triple" default: "double"
|
||||||
|
```
|
||||||
|
|
||||||
|
These can go in the Printer management UI (per-printer toggle) and in the
|
||||||
|
Print Settings tab (global default pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Beep bytes are sent over the same TCP socket as print data — no separate connection needed.
|
||||||
|
- The buzzer is hardware-limited; very short intervals (< 50ms) may be ignored.
|
||||||
|
- Beeping does NOT require paper to be loaded or printing to succeed — it fires independently.
|
||||||
|
- If spoof-printing mode is ON, the beep should also be suppressed (no real connection is made).
|
||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
cloud_backend:
|
cloud_backend:
|
||||||
build: ./cloud_backend
|
build: ./cloud_backend
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8011:8001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./cloud_backend/.env
|
- ./cloud_backend/.env
|
||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./local_backend
|
build: ./local_backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8010:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./local_backend/.env
|
- ./local_backend/.env
|
||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./waiter_pwa:/app
|
- ./waiter_pwa:/app
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5183:5173"
|
||||||
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./waiter_pwa/.env
|
- ./waiter_pwa/.env
|
||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./manager_dashboard:/app
|
- ./manager_dashboard:/app
|
||||||
ports:
|
ports:
|
||||||
- "5174:5174"
|
- "5184:5174"
|
||||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./manager_dashboard/.env
|
- ./manager_dashboard/.env
|
||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./sysadmin_panel:/app
|
- ./sysadmin_panel:/app
|
||||||
ports:
|
ports:
|
||||||
- "5175:5175"
|
- "5185:5175"
|
||||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./sysadmin_panel/.env
|
- ./sysadmin_panel/.env
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from routers import shifts as shifts_router
|
|||||||
from routers import settings as settings_router
|
from routers import settings as settings_router
|
||||||
from routers import flags as flags_router
|
from routers import flags as flags_router
|
||||||
from routers import messages as messages_router
|
from routers import messages as messages_router
|
||||||
|
from routers import sse as sse_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -111,10 +112,13 @@ def _run_migrations():
|
|||||||
name VARCHAR NOT NULL,
|
name VARCHAR NOT NULL,
|
||||||
emoji VARCHAR,
|
emoji VARCHAR,
|
||||||
color VARCHAR DEFAULT '#6b7280',
|
color VARCHAR DEFAULT '#6b7280',
|
||||||
|
text_color VARCHAR DEFAULT NULL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)""",
|
)""",
|
||||||
|
# Migration: add text_color if upgrading from older schema
|
||||||
|
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
|
||||||
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
|
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
table_id INTEGER NOT NULL REFERENCES tables(id),
|
table_id INTEGER NOT NULL REFERENCES tables(id),
|
||||||
@@ -177,6 +181,25 @@ def _run_migrations():
|
|||||||
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
# Auto-expand flag for sub-categories on the PWA accordion
|
# Auto-expand flag for sub-categories on the PWA accordion
|
||||||
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Printer protocol field
|
||||||
|
"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:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -189,6 +212,9 @@ def _run_migrations():
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
import asyncio
|
||||||
|
from services.sse_bus import init_loop
|
||||||
|
init_loop(asyncio.get_running_loop())
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_run_migrations()
|
_run_migrations()
|
||||||
sync_task = await start_cloud_sync()
|
sync_task = await start_cloud_sync()
|
||||||
@@ -228,3 +254,4 @@ app.include_router(shifts_router.router, prefix="/api/shifts", tag
|
|||||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||||
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
|
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
|
||||||
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
||||||
|
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class TableFlagDef(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
emoji = Column(String, nullable=True)
|
emoji = Column(String, nullable=True)
|
||||||
color = Column(String, nullable=True, default="#6b7280") # hex
|
color = Column(String, nullable=True, default="#6b7280") # hex background
|
||||||
|
text_color = Column(String, nullable=True, default=None) # hex text; None = white
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|||||||
@@ -93,13 +93,17 @@ class OrderAuditLog(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
event_type = Column(String, nullable=False)
|
event_type = Column(String, nullable=False)
|
||||||
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
||||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED)
|
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
|
||||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||||
payment_method = Column(String, nullable=True)
|
payment_method = Column(String, nullable=True)
|
||||||
note = Column(Text, nullable=True)
|
note = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
# Emergency offline payment fields
|
||||||
|
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
|
||||||
|
offline_at = Column(String, nullable=True) # ISO timestamp from client
|
||||||
|
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
|
||||||
|
|
||||||
order = relationship("Order", back_populates="audit_logs")
|
order = relationship("Order", back_populates="audit_logs")
|
||||||
waiter = relationship("User")
|
waiter = relationship("User")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Printer(Base):
|
|||||||
ip_address = Column(String, nullable=False)
|
ip_address = Column(String, nullable=False)
|
||||||
port = Column(Integer, default=9100, nullable=False)
|
port = Column(Integer, default=9100, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||||
|
|
||||||
products = relationship("Product", back_populates="printer_zone")
|
products = relationship("Product", back_populates="printer_zone")
|
||||||
print_logs = relationship("PrintLog", back_populates="printer")
|
print_logs = relationship("PrintLog", back_populates="printer")
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class ProductQuickOption(Base):
|
|||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_compact = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
product = relationship("Product", back_populates="quick_options")
|
product = relationship("Product", back_populates="quick_options")
|
||||||
|
|
||||||
|
|||||||
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.")
|
||||||
@@ -1,305 +1,343 @@
|
|||||||
"""
|
"""
|
||||||
Printer font & symbol test script.
|
Printer comprehensive test script — Jolimark TP850UE
|
||||||
Usage (inside Docker): python print_test.py [IP] [PORT]
|
Usage: python print_test.py [IP] [PORT]
|
||||||
Defaults to 10.98.20.25:9100
|
Default: 10.98.20.25:9100
|
||||||
|
|
||||||
|
Prints 6 pages:
|
||||||
|
Page 1 — ESC ! modes, Font A, English
|
||||||
|
Page 2 — ESC ! modes, Font B, English
|
||||||
|
Page 3 — ESC ! modes, Font A, Greek
|
||||||
|
Page 4 — ESC ! modes, Font B, Greek
|
||||||
|
Page 5 — GS ! character size multipliers (both fonts)
|
||||||
|
Page 6 — Beep tests + misc (underline, invert, symbols)
|
||||||
|
|
||||||
|
ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
|
||||||
|
Bit 0 (0x01) — Font B instead of Font A
|
||||||
|
Bit 3 (0x08) — Emphasize / Bold
|
||||||
|
Bit 4 (0x10) — Double-height
|
||||||
|
Bit 5 (0x20) — Double-width
|
||||||
|
Bit 7 (0x80) — Underline
|
||||||
|
|
||||||
|
GS ! (0x1D 0x21 n) character size multiplier:
|
||||||
|
Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||||
|
High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||||
|
e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
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
|
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||||
|
|
||||||
from escpos.printer import Network
|
from escpos.printer import Network
|
||||||
|
|
||||||
|
|
||||||
|
# ── Low-level helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _gr(text: str) -> bytes:
|
def _gr(text: str) -> bytes:
|
||||||
return text.encode('cp737', errors='replace')
|
return text.encode('cp737', errors='replace')
|
||||||
|
|
||||||
def _open():
|
def _open():
|
||||||
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||||
p._raw(b'\x1b\x40') # ESC @ reset
|
p._raw(b'\x1b\x40') # ESC @ — full reset
|
||||||
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
|
p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
|
||||||
return p
|
return p
|
||||||
|
|
||||||
def _t(p, text: str):
|
def _t(p, text: str):
|
||||||
p._raw(_gr(text))
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
def _reset(p):
|
||||||
|
"""Reset to: Font A, normal size, no bold, left-align."""
|
||||||
|
p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
|
||||||
|
p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
|
||||||
|
p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
|
||||||
|
p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
|
||||||
|
p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
|
||||||
|
|
||||||
|
def _center(p): p._raw(b'\x1b\x61\x01')
|
||||||
|
def _left(p): p._raw(b'\x1b\x61\x00')
|
||||||
|
|
||||||
def _divider(p, char="-", width=48):
|
def _divider(p, char="-", width=48):
|
||||||
p._raw(b'\x1b\x61\x00')
|
_left(p)
|
||||||
_t(p, char * width + "\n")
|
_t(p, char * width + "\n")
|
||||||
|
|
||||||
def _center(p):
|
def _page_header(p, title: str):
|
||||||
p._raw(b'\x1b\x61\x01')
|
_center(p)
|
||||||
|
p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
|
||||||
|
_t(p, title + "\n")
|
||||||
|
_reset(p)
|
||||||
|
_divider(p, "=")
|
||||||
|
|
||||||
def _left(p):
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
|
||||||
|
|
||||||
# ── ESC ! n byte reference ──────────────────────────────────────────────────
|
# ── ESC ! mode table ───────────────────────────────────────────────────────────
|
||||||
# Bit 0 → underline (not tested, minor)
|
#
|
||||||
# Bit 1 → double-strike (bold)
|
# Each entry: (esc_bang_byte, esc_e_bold, label)
|
||||||
# Bit 3 → double-height
|
# esc_bang_byte sets the mode via ESC ! n
|
||||||
# Bit 4 → double-width
|
# esc_e_bold adds ESC E on top (independent bold layer)
|
||||||
# Bit 5 → delete-line
|
# We test every useful combination so you can see the exact visual result.
|
||||||
# Bit 7 → bold (ESC E alias in some models)
|
|
||||||
# Common combos used here:
|
|
||||||
# 0x00 = normal
|
|
||||||
# 0x08 = double-height only (48 chars wide)
|
|
||||||
# 0x10 = double-height only (alt bit) (48 chars wide)
|
|
||||||
# 0x18 = double-height + bold
|
|
||||||
# 0x20 = double-width only (24 chars wide)
|
|
||||||
# 0x30 = double-width + double-height (24 chars wide)
|
|
||||||
# 0x38 = double-width + double-height + bold
|
|
||||||
# 0x48 = double-height (bit 6 combo — some printers)
|
|
||||||
|
|
||||||
MODES = [
|
ESC_BANG_MODES = [
|
||||||
(0x00, "Normal (0x00)"),
|
# (byte, extra_bold, label)
|
||||||
(0x08, "Double-height bit3 (0x08)"),
|
(0x00, False, "0x00 Normal"),
|
||||||
(0x10, "Double-height bit4 (0x10)"),
|
(0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
|
||||||
(0x18, "Double-height + Bold (0x18)"),
|
(0x08, False, "0x08 Bold only (bit3)"),
|
||||||
(0x20, "Double-width (0x20)"),
|
(0x10, False, "0x10 Double-height (bit4)"),
|
||||||
(0x30, "Double-width + Double-height (0x30)"),
|
(0x10, True, "0x10 +ESC E Double-height + Bold"),
|
||||||
(0x38, "Double-width + Double-height + Bold (0x38)"),
|
(0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
|
||||||
|
(0x20, False, "0x20 Double-width (bit5)"),
|
||||||
|
(0x20, True, "0x20 +ESC E Double-width + Bold"),
|
||||||
|
(0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
|
||||||
|
(0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
|
||||||
|
(0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── Section 1 — Font sizes & styles, English ───────────────────────────────
|
|
||||||
|
|
||||||
def section_english(p):
|
def _esc_bang_section(p, english: bool):
|
||||||
_center(p)
|
lang = "EN" if english else "GR"
|
||||||
p._raw(b'\x1b\x21\x38')
|
sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
|
||||||
_t(p, "=== FONT SIZES (EN) ===\n")
|
sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p, "=")
|
|
||||||
|
|
||||||
for code, label in MODES:
|
for (byte_val, extra_bold, label) in ESC_BANG_MODES:
|
||||||
_left(p)
|
_left(p)
|
||||||
_t(p, f"[{label}]\n")
|
# Print the label in small normal text first
|
||||||
p._raw(bytes([0x1b, 0x21, code]))
|
|
||||||
_t(p, "TEST PRINT Hello World Abc123\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
# bold on/off via ESC E
|
|
||||||
_t(p, " -> bold ON: ")
|
|
||||||
p._raw(b'\x1b\x45\x01')
|
|
||||||
p._raw(bytes([0x1b, 0x21, code]))
|
|
||||||
_t(p, "Bold Sample\n")
|
|
||||||
p._raw(b'\x1b\x45\x00')
|
p._raw(b'\x1b\x45\x00')
|
||||||
p._raw(b'\x1b\x21\x00')
|
_t(p, f"[{label}]\n")
|
||||||
|
|
||||||
|
# Apply mode
|
||||||
|
p._raw(bytes([0x1b, 0x21, byte_val]))
|
||||||
|
if extra_bold:
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
|
||||||
|
_t(p, sample_normal + "\n")
|
||||||
|
_t(p, sample_lower + "\n")
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
_reset(p)
|
||||||
_t(p, "\n")
|
_t(p, "\n")
|
||||||
|
|
||||||
_divider(p)
|
_divider(p)
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
|
|
||||||
|
|
||||||
def section_greek(p):
|
# ── Pages 1–4: ESC ! modes ────────────────────────────────────────────────────
|
||||||
_center(p)
|
|
||||||
p._raw(b'\x1b\x21\x38')
|
|
||||||
_t(p, "=== FONT SIZES (GR) ===\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p, "=")
|
|
||||||
|
|
||||||
for code, label in MODES:
|
def page_esc_bang(font_b: bool, english: bool):
|
||||||
|
font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
|
||||||
|
lang_label = "GREEK" if not english else "ENGLISH"
|
||||||
|
p = _open()
|
||||||
|
|
||||||
|
# Select font
|
||||||
|
p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
|
||||||
|
|
||||||
|
_page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}")
|
||||||
|
_t(p, f"Font: {font_label}\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
_esc_bang_section(p, english)
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Combinations worth seeing: square multipliers + some asymmetric
|
||||||
|
GS_SIZES = [
|
||||||
|
(0x00, "1x1 normal"),
|
||||||
|
(0x01, "1w x 2h"),
|
||||||
|
(0x10, "2w x 1h"),
|
||||||
|
(0x11, "2x2"),
|
||||||
|
(0x22, "3x3"),
|
||||||
|
(0x33, "4x4"),
|
||||||
|
(0x44, "5x5"),
|
||||||
|
(0x55, "6x6"),
|
||||||
|
(0x02, "1w x 3h"),
|
||||||
|
(0x20, "3w x 1h"),
|
||||||
|
(0x21, "3w x 2h"),
|
||||||
|
(0x12, "2w x 3h"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def page_gs_sizes():
|
||||||
|
p = _open()
|
||||||
|
_page_header(p, "GS! SIZE MULTIPLIERS")
|
||||||
|
_t(p, "GS ! n (0x1D 0x21 n)\n")
|
||||||
|
_t(p, "Low nibble=height, High nibble=width\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
for (byte_val, label) in GS_SIZES:
|
||||||
_left(p)
|
_left(p)
|
||||||
_t(p, f"[{label}]\n")
|
# Label in tiny normal text
|
||||||
p._raw(bytes([0x1b, 0x21, code]))
|
|
||||||
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
|
|
||||||
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
p._raw(b'\x1b\x45\x01')
|
|
||||||
p._raw(bytes([0x1b, 0x21, code]))
|
|
||||||
_t(p, "Bold: Καλημερα Κοσμε\n")
|
|
||||||
p._raw(b'\x1b\x45\x00')
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, f"[n=0x{byte_val:02X} {label}]\n")
|
||||||
|
|
||||||
|
# Font A sample
|
||||||
|
p._raw(b'\x1b\x4d\x00')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "Aa SAMPLE\n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
|
||||||
|
# Font B sample on same size
|
||||||
|
p._raw(b'\x1b\x4d\x01')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "Bb SMALL\n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
p._raw(b'\x1b\x4d\x00') # back to Font A
|
||||||
|
|
||||||
_t(p, "\n")
|
_t(p, "\n")
|
||||||
|
|
||||||
_divider(p)
|
_divider(p)
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
|
|
||||||
|
|
||||||
def section_ascii_symbols(p):
|
|
||||||
_center(p)
|
|
||||||
p._raw(b'\x1b\x21\x18') # double-height bold for header
|
|
||||||
_t(p, "=== ASCII SYMBOLS ===\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p, "=")
|
|
||||||
_left(p)
|
|
||||||
|
|
||||||
# Printable ASCII 0x20 – 0x7E, 16 per line
|
|
||||||
chars = [chr(c) for c in range(0x20, 0x7F)]
|
|
||||||
line = ""
|
|
||||||
for i, ch in enumerate(chars):
|
|
||||||
line += ch + " "
|
|
||||||
if (i + 1) % 24 == 0:
|
|
||||||
_t(p, line + "\n")
|
|
||||||
line = ""
|
|
||||||
if line:
|
|
||||||
_t(p, line + "\n")
|
|
||||||
|
|
||||||
|
# Also show GS ! combined with ESC ! bold
|
||||||
_t(p, "\n")
|
_t(p, "\n")
|
||||||
_t(p, "Notable:\n")
|
|
||||||
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
|
|
||||||
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
|
|
||||||
_t(p, " Currency : $ %\n")
|
|
||||||
_divider(p)
|
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 4 — CP737 extended chars (0x80–0xFF) ───────────────────────────
|
|
||||||
|
|
||||||
def section_extended(p):
|
|
||||||
_center(p)
|
|
||||||
p._raw(b'\x1b\x21\x18')
|
|
||||||
_t(p, "=== CP737 EXTENDED (0x80-FF) ===\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p, "=")
|
_divider(p, "=")
|
||||||
_left(p)
|
|
||||||
_t(p, "Hex offset rows x16 columns\n\n")
|
|
||||||
|
|
||||||
for row in range(8): # 0x80, 0x90 ... 0xF0
|
|
||||||
base = 0x80 + row * 16
|
|
||||||
row_bytes = bytes(range(base, base + 16))
|
|
||||||
label = f"0x{base:02X}: "
|
|
||||||
p._raw(_gr(label))
|
|
||||||
p._raw(row_bytes)
|
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
_t(p, "\n")
|
|
||||||
_t(p, "Key CP737 specials:\n")
|
|
||||||
specials = [
|
|
||||||
(0xB3, "─ thin horiz line"),
|
|
||||||
(0xC4, "─ double line"),
|
|
||||||
(0xBA, "│ vert line"),
|
|
||||||
(0xBB, "┐ corner"),
|
|
||||||
(0xBC, "┘ corner"),
|
|
||||||
(0xC9, "╔ corner"),
|
|
||||||
(0xCA, "╩ junction"),
|
|
||||||
(0xCB, "╦ junction"),
|
|
||||||
(0xCC, "╠ junction"),
|
|
||||||
(0xCD, "═ double horiz"),
|
|
||||||
(0xCE, "╬ cross"),
|
|
||||||
(0xC8, "╚ corner"),
|
|
||||||
(0xBB, "╗ corner"),
|
|
||||||
(0xBC, "╝ corner"),
|
|
||||||
(0xDB, "█ full block"),
|
|
||||||
(0xDC, "▄ lower block"),
|
|
||||||
(0xDF, "▀ upper block"),
|
|
||||||
(0xB0, "░ light shade"),
|
|
||||||
(0xB1, "▒ medium shade"),
|
|
||||||
(0xB2, "▓ dark shade"),
|
|
||||||
(0xF8, "° degree"),
|
|
||||||
(0xF9, "· middle dot"),
|
|
||||||
(0xFA, "· bullet dot"),
|
|
||||||
(0xFB, "√ check / tick"),
|
|
||||||
(0xFE, "■ filled square"),
|
|
||||||
]
|
|
||||||
for code, desc in specials:
|
|
||||||
row_bytes = bytes([code, 0x20]) # char + space
|
|
||||||
p._raw(row_bytes)
|
|
||||||
_t(p, f" {desc}\n")
|
|
||||||
|
|
||||||
_divider(p)
|
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 5 — Underline ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def section_underline(p):
|
|
||||||
_center(p)
|
|
||||||
p._raw(b'\x1b\x21\x18')
|
|
||||||
_t(p, "=== UNDERLINE TEST ===\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_t(p, "GS! + ESC E bold combined:\n")
|
||||||
_divider(p, "=")
|
_divider(p, "=")
|
||||||
_left(p)
|
for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, f"[{label} + bold]\n")
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "BOLD LARGE\n")
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
# ESC - n : underline 0=off, 1=thin, 2=thick
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page 6: Beep + misc ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def page_beep_misc():
|
||||||
|
p = _open()
|
||||||
|
_page_header(p, "BEEP + MISC TESTS")
|
||||||
|
|
||||||
|
# ── Beep section ──
|
||||||
|
_t(p, "BEEP TESTS\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
_t(p, "Sending beeps now...\n\n")
|
||||||
|
|
||||||
|
# BEL — single beep (0x07)
|
||||||
|
_t(p, "[1] BEL single beep (0x07)\n")
|
||||||
|
p._raw(b'\x07')
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# ESC BEL n1 n2 n3 — beep for appointment
|
||||||
|
# n1=beep length (100ms units), n2=intermission (100ms), n3=count
|
||||||
|
_t(p, "[2] ESC BEL: 1 beep, 200ms long\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
_t(p, "[3] ESC BEL: 3 short beeps\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
_t(p, "[4] ESC BEL: 1 long beep (500ms)\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
_t(p, "[5] GS BEL: 2 beeps\n")
|
||||||
|
p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
_t(p, "Beep tests done.\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# ── Underline ──
|
||||||
|
_t(p, "\nUNDERLINE\n")
|
||||||
|
_divider(p, "-")
|
||||||
for ul in [1, 2]:
|
for ul in [1, 2]:
|
||||||
p._raw(bytes([0x1b, 0x2d, ul]))
|
p._raw(bytes([0x1b, 0x2d, ul]))
|
||||||
_t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
|
_t(p, f"Underline mode {ul}: Hello World 123\n")
|
||||||
p._raw(b'\x1b\x2d\x00') # off
|
p._raw(b'\x1b\x2d\x00')
|
||||||
_t(p, "\n")
|
_t(p, "\n")
|
||||||
_divider(p)
|
_divider(p)
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
|
# ── White-on-black invert ──
|
||||||
|
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
|
||||||
def section_invert(p):
|
_divider(p, "-")
|
||||||
_center(p)
|
|
||||||
p._raw(b'\x1b\x21\x18')
|
|
||||||
_t(p, "=== INVERT (white-on-black) ===\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p, "=")
|
|
||||||
_left(p)
|
|
||||||
|
|
||||||
# GS B n — invert
|
|
||||||
p._raw(b'\x1d\x42\x01')
|
p._raw(b'\x1d\x42\x01')
|
||||||
_t(p, " INVERTED TEXT SAMPLE \n")
|
_t(p, " INVERTED NORMAL \n")
|
||||||
|
p._raw(b'\x1d\x21\x11') # 2x2 inverted
|
||||||
|
_t(p, " INVERTED 2x2 \n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
p._raw(b'\x1d\x42\x00')
|
p._raw(b'\x1d\x42\x00')
|
||||||
_t(p, "Normal text after invert\n")
|
_t(p, "Normal after invert\n")
|
||||||
_t(p, "\n")
|
|
||||||
_divider(p)
|
_divider(p)
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Section 7 — QR Code sample ────────────────────────────────────────────
|
# ── 90-degree rotation ──
|
||||||
|
_t(p, "\n90-DEGREE ROTATION (ESC V)\n")
|
||||||
def section_qr(p):
|
_divider(p, "-")
|
||||||
_center(p)
|
p._raw(b'\x1b\x56\x01')
|
||||||
p._raw(b'\x1b\x21\x18')
|
_t(p, "ROTATED TEXT\n")
|
||||||
_t(p, "=== QR CODE SAMPLE ===\n")
|
p._raw(b'\x1b\x56\x00')
|
||||||
p._raw(b'\x1b\x21\x00')
|
_t(p, "Normal again\n")
|
||||||
_divider(p, "=")
|
|
||||||
|
|
||||||
data = b"https://pos.test"
|
|
||||||
# GS ( k — QR store data
|
|
||||||
store_len = len(data) + 3
|
|
||||||
p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data)
|
|
||||||
# GS ( k — set size (module=6)
|
|
||||||
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06')
|
|
||||||
# GS ( k — error correction level M
|
|
||||||
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31')
|
|
||||||
# GS ( k — print
|
|
||||||
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30')
|
|
||||||
|
|
||||||
_t(p, "\nhttps://pos.test\n\n")
|
|
||||||
_divider(p)
|
_divider(p)
|
||||||
p._raw(b'\n')
|
|
||||||
|
|
||||||
# ── Main ───────────────────────────────────────────────────────────────────
|
# ── CP737 useful symbols at normal size ──
|
||||||
|
_t(p, "\nUSEFUL CP737 SYMBOLS\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
symbols = [
|
||||||
|
(0xFB, "tick / checkmark"),
|
||||||
|
(0xFE, "filled square"),
|
||||||
|
(0xF9, "middle dot"),
|
||||||
|
(0xFA, "small bullet"),
|
||||||
|
(0xF8, "degree"),
|
||||||
|
(0xDB, "full block"),
|
||||||
|
(0xDC, "lower half block"),
|
||||||
|
(0xDF, "upper half block"),
|
||||||
|
(0xB0, "light shade"),
|
||||||
|
(0xB1, "medium shade"),
|
||||||
|
(0xB2, "dark shade"),
|
||||||
|
(0xC4, "thin horiz line"),
|
||||||
|
(0xCD, "double horiz line"),
|
||||||
|
(0xBA, "vertical bar"),
|
||||||
|
(0xC9, "top-left corner dbl"),
|
||||||
|
(0xBB, "top-right corner dbl"),
|
||||||
|
(0xC8, "bot-left corner dbl"),
|
||||||
|
(0xBC, "bot-right corner dbl"),
|
||||||
|
]
|
||||||
|
for code, desc in symbols:
|
||||||
|
p._raw(bytes([code, 0x20, code, 0x20, code, 0x20]))
|
||||||
|
_t(p, f" {desc}\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...")
|
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
|
||||||
|
print("Printing 6 pages...\n")
|
||||||
|
|
||||||
# ---- PAGE 1: English fonts ----
|
page_esc_bang(font_b=False, english=True)
|
||||||
p = _open()
|
print("Page 1 done — ESC! modes, Font A, English")
|
||||||
section_english(p)
|
|
||||||
p._raw(b'\n\n\n')
|
|
||||||
p.cut()
|
|
||||||
p.close()
|
|
||||||
print("Page 1 sent (English fonts)")
|
|
||||||
|
|
||||||
# ---- PAGE 2: Greek fonts ----
|
page_esc_bang(font_b=True, english=True)
|
||||||
p = _open()
|
print("Page 2 done — ESC! modes, Font B, English")
|
||||||
section_greek(p)
|
|
||||||
p._raw(b'\n\n\n')
|
|
||||||
p.cut()
|
|
||||||
p.close()
|
|
||||||
print("Page 2 sent (Greek fonts)")
|
|
||||||
|
|
||||||
# ---- PAGE 3: Symbols & special chars ----
|
page_esc_bang(font_b=False, english=False)
|
||||||
p = _open()
|
print("Page 3 done — ESC! modes, Font A, Greek")
|
||||||
section_ascii_symbols(p)
|
|
||||||
section_extended(p)
|
|
||||||
p._raw(b'\n\n\n')
|
|
||||||
p.cut()
|
|
||||||
p.close()
|
|
||||||
print("Page 3 sent (ASCII + CP737 extended)")
|
|
||||||
|
|
||||||
# ---- PAGE 4: Underline + Invert + QR ----
|
page_esc_bang(font_b=True, english=False)
|
||||||
p = _open()
|
print("Page 4 done — ESC! modes, Font B, Greek")
|
||||||
section_underline(p)
|
|
||||||
section_invert(p)
|
|
||||||
section_qr(p)
|
|
||||||
p._raw(b'\n\n\n')
|
|
||||||
p.cut()
|
|
||||||
p.close()
|
|
||||||
print("Page 4 sent (underline / invert / QR)")
|
|
||||||
|
|
||||||
print("Done — 4 pages printed.")
|
page_gs_sizes()
|
||||||
|
print("Page 5 done — GS! size multipliers")
|
||||||
|
|
||||||
|
page_beep_misc()
|
||||||
|
print("Page 6 done — Beep tests + misc")
|
||||||
|
|
||||||
|
print("\nAll done.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from models.flag import TableFlagDef, TableFlagAssignment
|
|||||||
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -124,9 +125,11 @@ def set_table_flags(
|
|||||||
))
|
))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return db.query(TableFlagAssignment).filter(
|
result = db.query(TableFlagAssignment).filter(
|
||||||
TableFlagAssignment.table_id == table_id
|
TableFlagAssignment.table_id == table_id
|
||||||
).all()
|
).all()
|
||||||
|
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -139,3 +142,4 @@ def clear_table_flags(
|
|||||||
TableFlagAssignment.table_id == table_id
|
TableFlagAssignment.table_id == table_id
|
||||||
).delete(synchronize_session=False)
|
).delete(synchronize_session=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from schemas.message import (
|
|||||||
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -113,7 +114,22 @@ def send_message(
|
|||||||
db.add(msg)
|
db.add(msg)
|
||||||
db.commit()
|
db.commit()
|
||||||
msg = _load_msg(db, msg.id)
|
msg = _load_msg(db, msg.id)
|
||||||
return _message_out(msg)
|
out = _message_out(msg)
|
||||||
|
# Broadcast to targeted users (empty list = all connected users)
|
||||||
|
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
|
||||||
|
broadcast_sync(
|
||||||
|
"message_sent",
|
||||||
|
{
|
||||||
|
"id": out.id,
|
||||||
|
"sender_id": out.sender_id,
|
||||||
|
"sender_name": out.sender_name,
|
||||||
|
"body": out.body,
|
||||||
|
"table_ids": out.table_ids,
|
||||||
|
"created_at": out.created_at.isoformat() if out.created_at else None,
|
||||||
|
},
|
||||||
|
user_ids=target_ids,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unread", response_model=List[StaffMessageOut])
|
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
|||||||
from models.user import User, WaiterZone
|
from models.user import User, WaiterZone
|
||||||
from models.table import Table
|
from models.table import Table
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
|
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class PrintOrderRequest(BaseModel):
|
class PrintOrderRequest(BaseModel):
|
||||||
@@ -33,6 +33,7 @@ class MoveItemsRequest(BaseModel):
|
|||||||
|
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -159,6 +160,7 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
|
|||||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
@@ -209,7 +211,7 @@ def add_items(
|
|||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
|
||||||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
|
||||||
return {"order": order, "print_results": print_results}
|
return {"order": order, "print_results": print_results}
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +297,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
||||||
amount=total_paid, payment_method=body.payment_method)
|
amount=total_paid, payment_method=body.payment_method)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||||
return {"status": order.status, "paid_item_ids": paid_ids}
|
return {"status": order.status, "paid_item_ids": paid_ids}
|
||||||
|
|
||||||
|
|
||||||
@@ -312,9 +315,105 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
|||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||||
return {"status": "closed"}
|
return {"status": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/pay-offline")
|
||||||
|
def pay_items_offline(
|
||||||
|
order_id: int,
|
||||||
|
body: OfflinePaymentRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sync an emergency payment that was taken while the server was offline.
|
||||||
|
The UUID prevents double-processing. If a payment with the same UUID already
|
||||||
|
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
|
||||||
|
than silently dropped — so managers can reconcile.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Check for duplicate UUID on this order
|
||||||
|
existing_uuid = db.query(OrderAuditLog).filter(
|
||||||
|
OrderAuditLog.order_id == order_id,
|
||||||
|
OrderAuditLog.offline_uuid == body.uuid,
|
||||||
|
).first()
|
||||||
|
is_duplicate = existing_uuid is not None
|
||||||
|
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id.in_(body.item_ids),
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Reject empty payments — client had no offline snapshot for this table
|
||||||
|
if not items and not is_duplicate:
|
||||||
|
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
|
||||||
|
|
||||||
|
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
|
||||||
|
try:
|
||||||
|
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
paid_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
active_shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
total_paid = 0.0
|
||||||
|
paid_ids = []
|
||||||
|
if not is_duplicate:
|
||||||
|
for item in items:
|
||||||
|
item.status = "paid"
|
||||||
|
item.paid_by = user.id
|
||||||
|
item.paid_at = paid_at
|
||||||
|
item.payment_method = body.payment_method
|
||||||
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
|
total_paid += item.unit_price * item.quantity
|
||||||
|
paid_ids.append(item.id)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
active_remaining = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||||
|
).count()
|
||||||
|
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||||
|
else:
|
||||||
|
# Duplicate — compute total for audit record without changing item state
|
||||||
|
total_paid = sum(i.unit_price * i.quantity for i in items)
|
||||||
|
paid_ids = [i.id for i in items]
|
||||||
|
|
||||||
|
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
|
||||||
|
db.add(OrderAuditLog(
|
||||||
|
order_id=order_id,
|
||||||
|
event_type="PAYMENT_OFFLINE",
|
||||||
|
waiter_id=user.id,
|
||||||
|
item_ids=json.dumps(paid_ids),
|
||||||
|
amount=total_paid,
|
||||||
|
payment_method=body.payment_method,
|
||||||
|
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
|
||||||
|
offline_uuid=body.uuid,
|
||||||
|
offline_at=body.offline_at,
|
||||||
|
is_duplicate=1 if is_duplicate else 0,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not is_duplicate:
|
||||||
|
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": order.status if not is_duplicate else "duplicate",
|
||||||
|
"paid_item_ids": paid_ids,
|
||||||
|
"is_duplicate": is_duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
@@ -325,6 +424,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
|||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{order_id}/assign-waiter")
|
@router.put("/{order_id}/assign-waiter")
|
||||||
@@ -444,6 +544,7 @@ def transfer_order(
|
|||||||
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
@@ -517,6 +618,8 @@ def merge_order(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(target)
|
db.refresh(target)
|
||||||
|
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
|
||||||
|
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def _replace_quick_options(db, product, quick_options):
|
|||||||
sort_order=qo.sort_order if qo.sort_order else i,
|
sort_order=qo.sort_order if qo.sort_order else i,
|
||||||
is_favorite=qo.is_favorite,
|
is_favorite=qo.is_favorite,
|
||||||
favorite_sort_order=qo.favorite_sort_order,
|
favorite_sort_order=qo.favorite_sort_order,
|
||||||
|
is_compact=qo.is_compact,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
|||||||
sort_order=qo.sort_order if qo.sort_order else i,
|
sort_order=qo.sort_order if qo.sort_order else i,
|
||||||
is_favorite=qo.is_favorite,
|
is_favorite=qo.is_favorite,
|
||||||
favorite_sort_order=qo.favorite_sort_order,
|
favorite_sort_order=qo.favorite_sort_order,
|
||||||
|
is_compact=qo.is_compact,
|
||||||
))
|
))
|
||||||
for opt in body.options:
|
for opt in body.options:
|
||||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ VALID_SETTINGS = {
|
|||||||
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||||
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||||
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||||
|
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
|
||||||
|
# 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 = {
|
DEFAULTS = {
|
||||||
@@ -24,6 +38,18 @@ DEFAULTS = {
|
|||||||
"business_day.force_close_allowed": "true",
|
"business_day.force_close_allowed": "true",
|
||||||
"system.timezone": "Europe/Athens",
|
"system.timezone": "Europe/Athens",
|
||||||
"ui.table_colours": "",
|
"ui.table_colours": "",
|
||||||
|
"dev.spoof_printing": "false",
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
from schemas.printer import PrinterUpdate, PrinterOut
|
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
||||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import printer_service
|
from services import printer_service
|
||||||
@@ -40,7 +40,16 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
|
|||||||
|
|
||||||
@router.get("/printers", response_model=List[PrinterOut])
|
@router.get("/printers", response_model=List[PrinterOut])
|
||||||
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
return db.query(Printer).filter(Printer.is_active == True).all()
|
return db.query(Printer).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers", response_model=PrinterOut)
|
||||||
|
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = Printer(**body.model_dump())
|
||||||
|
db.add(printer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(printer)
|
||||||
|
return printer
|
||||||
|
|
||||||
|
|
||||||
@router.post("/printers/test")
|
@router.post("/printers/test")
|
||||||
@@ -52,8 +61,17 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
|
|||||||
return {"success": success, "error": error}
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers/test-order")
|
||||||
|
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
|
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
|
||||||
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
||||||
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)):
|
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
if not printer:
|
if not printer:
|
||||||
raise HTTPException(status_code=404, detail="Printer not found")
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
@@ -64,6 +82,16 @@ def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(g
|
|||||||
return printer
|
return printer
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/printers/{printer_id}")
|
||||||
|
def delete_printer(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")
|
||||||
|
db.delete(printer)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lock")
|
@router.post("/lock")
|
||||||
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||||
license_state["locked"] = True
|
license_state["locked"] = True
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from schemas.table import (
|
|||||||
TableBatchCreate,
|
TableBatchCreate,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
|||||||
db.add(table)
|
db.add(table)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(table)
|
db.refresh(table)
|
||||||
|
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class FlagDefCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
emoji: Optional[str] = None
|
emoji: Optional[str] = None
|
||||||
color: Optional[str] = "#6b7280"
|
color: Optional[str] = "#6b7280"
|
||||||
|
text_color: Optional[str] = None
|
||||||
sort_order: Optional[int] = 0
|
sort_order: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ class FlagDefUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
emoji: Optional[str] = None
|
emoji: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
text_color: Optional[str] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ class FlagDefOut(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
emoji: Optional[str] = None
|
emoji: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
text_color: Optional[str] = None
|
||||||
sort_order: int
|
sort_order: int
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ class SelectedOptionInput(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
price_delta: Optional[float] = None
|
price_delta: Optional[float] = None
|
||||||
extra_cost: Optional[float] = None
|
extra_cost: Optional[float] = None
|
||||||
|
# type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub"
|
||||||
|
# Omitted by old clients — print code falls back gracefully.
|
||||||
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class OrderItemInput(BaseModel):
|
class OrderItemInput(BaseModel):
|
||||||
@@ -73,6 +76,13 @@ class PayItemsRequest(BaseModel):
|
|||||||
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
||||||
|
|
||||||
|
|
||||||
|
class OfflinePaymentRequest(BaseModel):
|
||||||
|
uuid: str # client-generated UUID, used for duplicate detection
|
||||||
|
item_ids: List[int]
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline
|
||||||
|
|
||||||
|
|
||||||
class AssignWaiterRequest(BaseModel):
|
class AssignWaiterRequest(BaseModel):
|
||||||
waiter_id: int
|
waiter_id: int
|
||||||
|
|
||||||
@@ -93,6 +103,8 @@ class AuditLogOut(BaseModel):
|
|||||||
payment_method: Optional[str] = None
|
payment_method: Optional[str] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
created_at: UTCDatetime
|
created_at: UTCDatetime
|
||||||
|
offline_at: Optional[str] = None
|
||||||
|
is_duplicate: int = 0
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||||
|
|
||||||
|
|
||||||
class PrinterBase(BaseModel):
|
class PrinterBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
ip_address: str
|
ip_address: str
|
||||||
port: int = 9100
|
port: int = 9100
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
protocol: str = "escpos_tcp"
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterCreate(PrinterBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PrinterUpdate(BaseModel):
|
class PrinterUpdate(BaseModel):
|
||||||
@@ -14,6 +22,7 @@ class PrinterUpdate(BaseModel):
|
|||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
protocol: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PrinterOut(PrinterBase):
|
class PrinterOut(PrinterBase):
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ProductQuickOptionCreate(BaseModel):
|
|||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
is_favorite: bool = False
|
is_favorite: bool = False
|
||||||
favorite_sort_order: int = 0
|
favorite_sort_order: int = 0
|
||||||
|
is_compact: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ProductQuickOptionOut(BaseModel):
|
class ProductQuickOptionOut(BaseModel):
|
||||||
@@ -68,6 +69,7 @@ class ProductQuickOptionOut(BaseModel):
|
|||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
is_favorite: bool = False
|
is_favorite: bool = False
|
||||||
favorite_sort_order: int = 0
|
favorite_sort_order: int = 0
|
||||||
|
is_compact: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from database import SessionLocal
|
|||||||
from models.order import Order, OrderItem, PrintLog
|
from models.order import Order, OrderItem, PrintLog
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
|
from models.settings import PosSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,19 +47,115 @@ def _raw_text(p: Network, text: str):
|
|||||||
p._raw(_gr(text))
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
|
||||||
def _divider(p: Network):
|
_DIVIDER_CHARS = {
|
||||||
|
"dash": "-",
|
||||||
|
"equals": "=",
|
||||||
|
"star": "*",
|
||||||
|
"empty": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_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):
|
||||||
|
# 0 = normal
|
||||||
|
# 16 = double-height (bit4)
|
||||||
|
# 32 = double-width (bit5)
|
||||||
|
# 48 = double-height + double-width (bits 4+5)
|
||||||
|
# Bold applied via ESC E, caps applied in software before encoding.
|
||||||
|
|
||||||
|
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"
|
||||||
|
caps = len(parts) > 2 and parts[2] == "1"
|
||||||
|
return size, bold, caps
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0, False, False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_print_settings(db: Session) -> dict:
|
||||||
|
rows = db.query(PosSettings).filter(
|
||||||
|
PosSettings.key.in_(_PRINT_SETTING_KEYS)
|
||||||
|
).all()
|
||||||
|
settings = dict(_PRINT_SETTING_DEFAULTS)
|
||||||
|
for row in rows:
|
||||||
|
settings[row.key] = row.value
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _divider(p: Network, style: str = "dash"):
|
||||||
|
char = _DIVIDER_CHARS.get(style, "-")
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(_gr("-" * LINE_WIDTH + "\n"))
|
if char:
|
||||||
|
p._raw(_gr(char * LINE_WIDTH + "\n"))
|
||||||
|
else:
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
|
||||||
def _item_line(name: str, qty: int) -> str:
|
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
||||||
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
|
"""Build a dot-leader line ending with 'xN'.
|
||||||
qty_str = str(qty)
|
line_width must reflect the effective width at the chosen font size
|
||||||
gap = LINE_WIDTH - len(name) - len(qty_str)
|
(double-width fonts halve the available char count to 24)."""
|
||||||
if gap < 3:
|
suffix = f"x{qty}"
|
||||||
return f"{name} {qty_str}"
|
available = line_width - len(name) - len(suffix)
|
||||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
if available < 2:
|
||||||
return f"{name}{dots}{qty_str}"
|
# Name alone is too long — put qty on same line with a single space
|
||||||
|
return f"{name} {suffix}"
|
||||||
|
dots = (". " * ((available // 2) + 1))[:available]
|
||||||
|
return f"{name}{dots}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_font(p: Network, size: int, bold: bool):
|
||||||
|
p._raw(bytes([0x1b, 0x21, size]))
|
||||||
|
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_font(p: Network):
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
|
||||||
|
|
||||||
|
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
|
||||||
|
align: bytes = b'\x1b\x61\x00'):
|
||||||
|
"""Apply font, optionally capitalize, print text + newline, reset font."""
|
||||||
|
p._raw(align)
|
||||||
|
_apply_font(p, size, bold)
|
||||||
|
out = text.upper() if caps else text
|
||||||
|
_raw_text(p, out + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _greek_date(dt: datetime.datetime) -> str:
|
||||||
|
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
|
||||||
|
return dt.strftime("%H:%M %d-%m-%Y")
|
||||||
|
|
||||||
|
|
||||||
def check_printer(ip: str, port: int) -> bool:
|
def check_printer(ip: str, port: int) -> bool:
|
||||||
@@ -73,7 +170,19 @@ def check_printer(ip: str, port: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_spoof_mode() -> bool:
|
||||||
|
"""Stateless check — opens its own DB session. For use outside route_and_print."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return _is_spoof_mode(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping test print for %s", name)
|
||||||
|
return True, ""
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
@@ -91,71 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
|
||||||
|
"""Print a fake order using the current font/layout settings — for settings preview."""
|
||||||
|
if _is_spoof_mode(db):
|
||||||
|
logger.info("Spoof printing ON — dropping test order print")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# ── Fake data structures (no DB writes) ──────────────────────────────────
|
||||||
|
class _Table:
|
||||||
|
label = "O2"
|
||||||
|
number = 2
|
||||||
|
|
||||||
|
class _User:
|
||||||
|
nickname = "bonamin"
|
||||||
|
username = "bonamin"
|
||||||
|
|
||||||
|
class _Order:
|
||||||
|
id = 99
|
||||||
|
table = _Table()
|
||||||
|
opener = _User()
|
||||||
|
table_id = 2
|
||||||
|
opened_by = 1
|
||||||
|
notes = "Χωρις καψαλισμα παρακαλω"
|
||||||
|
|
||||||
|
class _Item:
|
||||||
|
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
|
||||||
|
self.product_id = product_id
|
||||||
|
self.quantity = quantity
|
||||||
|
self.selected_options = selected_options
|
||||||
|
self.removed_ingredients = removed_ingredients
|
||||||
|
self.notes = notes
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
items = [
|
||||||
|
# Item 1: Freddo Espresso — quick options + preference + note
|
||||||
|
_Item(
|
||||||
|
product_id=1001,
|
||||||
|
quantity=2,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
|
||||||
|
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
|
||||||
|
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=None,
|
||||||
|
notes="Πολυ κρυο παρακαλω",
|
||||||
|
),
|
||||||
|
# Item 2: Club Sandwich — extra with sub + removed ingredients
|
||||||
|
_Item(
|
||||||
|
product_id=1002,
|
||||||
|
quantity=1,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||||
|
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||||
|
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||||
|
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||||
|
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
|
||||||
|
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
|
||||||
|
notes=None,
|
||||||
|
),
|
||||||
|
# Item 3: Margherita — quick + extra + removed
|
||||||
|
_Item(
|
||||||
|
product_id=1003,
|
||||||
|
quantity=3,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
|
||||||
|
notes=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patch product lookup so _print_kitchen_ticket gets real names
|
||||||
|
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
|
||||||
|
|
||||||
|
# Monkey-patch db.query for Product only inside this call
|
||||||
|
_orig_query = db.query
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, model):
|
||||||
|
self._model = model
|
||||||
|
self._filter_id = None
|
||||||
|
def filter(self, *args):
|
||||||
|
# extract id from the filter expression value
|
||||||
|
for arg in args:
|
||||||
|
try:
|
||||||
|
self._filter_id = arg.right.value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self
|
||||||
|
def first(self):
|
||||||
|
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
|
||||||
|
class _P:
|
||||||
|
name = _FAKE_NAMES[self._filter_id]
|
||||||
|
return _P()
|
||||||
|
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
|
||||||
|
|
||||||
|
class _PatchedDB:
|
||||||
|
def query(self, model):
|
||||||
|
from models.product import Product as _Product
|
||||||
|
if model is _Product:
|
||||||
|
return _FakeQuery(model)
|
||||||
|
return _orig_query(model)
|
||||||
|
# delegate everything else to real db
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(db, name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
|
||||||
|
p.close()
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Test order print failed for %s:%s — %s", ip, port, e)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
# ── Receipt formatting ───────────────────────────────────────────────────────
|
# ── Receipt formatting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _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):
|
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
||||||
# Header
|
cfg = _load_print_settings(db)
|
||||||
p._raw(b'\x1b\x61\x01')
|
mode = cfg.get("print.ticket_mode", "detailed")
|
||||||
p._raw(b'\x1b\x21\x38') # bold + double height + double width
|
div = cfg.get("print.divider_style", "dash")
|
||||||
_raw_text(p, f"Παραγγελια #{order.id}\n")
|
compact = (mode == "compact")
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p)
|
|
||||||
|
|
||||||
# Meta
|
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
|
||||||
p._raw(b'\x1b\x61\x00')
|
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
|
||||||
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
|
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
|
||||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
|
||||||
_raw_text(p, f"Date: {now}\n")
|
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
|
||||||
_raw_text(p, f"Table: {order.table_id}\n")
|
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
|
||||||
_raw_text(p, f"Waiter: {order.opened_by}\n")
|
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
|
||||||
p._raw(b'\x1b\x21\x00')
|
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
|
||||||
_divider(p)
|
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
|
||||||
|
|
||||||
|
# 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())
|
||||||
|
|
||||||
|
# ── 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:
|
for item in items:
|
||||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
name = product.name if product else f"Product #{item.product_id}"
|
raw_name = product.name if product else f"Product #{item.product_id}"
|
||||||
|
item_name = raw_name.upper() if c_item else raw_name
|
||||||
|
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x45\x01') # bold on
|
_apply_font(p, sz_item, b_item)
|
||||||
_raw_text(p, _item_line(name, item.quantity) + "\n")
|
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
||||||
p._raw(b'\x1b\x45\x00') # bold off
|
_reset_font(p)
|
||||||
|
|
||||||
|
opts = _parse_options(item)
|
||||||
|
|
||||||
|
# Quick options (* marker)
|
||||||
|
if opts["quick"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, qty in opts["quick"]:
|
||||||
|
n = name.upper() if c_qk else name
|
||||||
|
parts.append(f"{n} x{qty}" if qty > 1 else n)
|
||||||
|
_apply_font(p, sz_qk, b_qk)
|
||||||
|
_raw_text(p, "* " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, qty in opts["quick"]:
|
||||||
|
n = name.upper() if c_qk else name
|
||||||
|
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
|
||||||
|
_apply_font(p, sz_qk, b_qk)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Preferences (> marker)
|
||||||
|
if opts["pref"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, sub in opts["pref"]:
|
||||||
|
n = name.upper() if c_pr else name
|
||||||
|
s = (sub.upper() if c_pr else sub) if sub else None
|
||||||
|
parts.append(f"{n} · {s}" if s else n)
|
||||||
|
_apply_font(p, sz_pr, b_pr)
|
||||||
|
_raw_text(p, "> " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, sub in opts["pref"]:
|
||||||
|
n = name.upper() if c_pr else name
|
||||||
|
s = (sub.upper() if c_pr else sub) if sub else None
|
||||||
|
line = f"> {n} · {s}" if s else f"> {n}"
|
||||||
|
_apply_font(p, sz_pr, b_pr)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Extras (+ marker)
|
||||||
|
if opts["extra"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, sub, qty in opts["extra"]:
|
||||||
|
n = name.upper() if c_ex else name
|
||||||
|
s = (sub.upper() if c_ex else sub) if sub else None
|
||||||
|
part = f"{n} · {s}" if s else n
|
||||||
|
if qty > 1:
|
||||||
|
part += f" · x{qty}"
|
||||||
|
parts.append(part)
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, "+ " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, sub, qty in opts["extra"]:
|
||||||
|
n = name.upper() if c_ex else name
|
||||||
|
s = (sub.upper() if c_ex else sub) if sub else None
|
||||||
|
line = f"+ {n}"
|
||||||
|
if s:
|
||||||
|
line += f" · {s}"
|
||||||
|
if qty > 1:
|
||||||
|
line += f" · x{qty}"
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Legacy untagged options
|
||||||
|
for entry in opts["unknown"]:
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, f"+ {entry}\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Removed ingredients (- marker)
|
||||||
if item.removed_ingredients:
|
if item.removed_ingredients:
|
||||||
try:
|
try:
|
||||||
removed_ids = json.loads(item.removed_ingredients)
|
removed = json.loads(item.removed_ingredients)
|
||||||
if removed_ids:
|
if removed:
|
||||||
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
|
names = [n.upper() if c_ing else n for n in removed]
|
||||||
except (json.JSONDecodeError, TypeError):
|
joined = " · ".join(names)
|
||||||
pass
|
_apply_font(p, sz_ing, b_ing)
|
||||||
|
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
|
||||||
if item.selected_options:
|
_reset_font(p)
|
||||||
try:
|
|
||||||
option_ids = json.loads(item.selected_options)
|
|
||||||
if option_ids:
|
|
||||||
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Per-item note
|
||||||
if item.notes:
|
if item.notes:
|
||||||
_raw_text(p, f" (i) {item.notes}\n")
|
note_text = item.notes.upper() if c_note else item.notes
|
||||||
|
_apply_font(p, sz_note, b_note)
|
||||||
|
if compact:
|
||||||
|
_raw_text(p, f"! {note_text}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"\n(!) {note_text}\n\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
# Blank line between items in detailed mode
|
||||||
|
if not compact:
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
_divider(p)
|
_divider(p, div)
|
||||||
|
|
||||||
|
# Order-level notes
|
||||||
if order.notes:
|
if order.notes:
|
||||||
p._raw(b'\x1b\x21\x30')
|
note_text = order.notes.upper() if c_onote else order.notes
|
||||||
_raw_text(p, "Σημειωσεις:\n")
|
_apply_font(p, sz_onote, b_onote)
|
||||||
p._raw(b'\x1b\x21\x10')
|
_raw_text(p, f"Σημ: {note_text}\n")
|
||||||
_raw_text(p, f"{order.notes}\n")
|
_reset_font(p)
|
||||||
p._raw(b'\x1b\x21\x00')
|
if not compact:
|
||||||
_divider(p)
|
_divider(p, div)
|
||||||
|
|
||||||
|
# Footer (detailed only)
|
||||||
|
if not compact:
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "Τελος Παραγγελιας\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
|
||||||
p._raw(b'\x1b\x21\x30')
|
|
||||||
_raw_text(p, "Τελος Παραγγελιας\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
p.cut()
|
p.cut()
|
||||||
|
|
||||||
@@ -164,6 +570,9 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
|
|
||||||
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
||||||
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping waiter report print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -222,6 +631,9 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
|
|
||||||
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
||||||
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping printer report print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -282,6 +694,9 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
|
|
||||||
def print_order_receipt(ip: str, port: int, receipt: dict):
|
def print_order_receipt(ip: str, port: int, receipt: dict):
|
||||||
"""Print a manager-triggered order receipt."""
|
"""Print a manager-triggered order receipt."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping order receipt print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -329,6 +744,9 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
|
|||||||
|
|
||||||
def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
||||||
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping order synopsis print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -408,7 +826,21 @@ def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> Lis
|
|||||||
return _do_route_and_print(order_id, item_ids, db)
|
return _do_route_and_print(order_id, item_ids, db)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_spoof_mode(db: Session) -> bool:
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == "dev.spoof_printing").first()
|
||||||
|
return row is not None and row.value == "true"
|
||||||
|
|
||||||
|
|
||||||
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||||
|
if _is_spoof_mode(db):
|
||||||
|
logger.info("Spoof printing ON — dropping print job for order %s", order_id)
|
||||||
|
for item_id in item_ids:
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||||
|
if item:
|
||||||
|
item.printed = True
|
||||||
|
db.commit()
|
||||||
|
return [{"printer_name": "spoof", "success": True, "error": None}]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
|||||||
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: 'Άνοιγμα',
|
ORDER_OPENED: 'Άνοιγμα',
|
||||||
ITEMS_ADDED: 'Προσθήκη',
|
ITEMS_ADDED: 'Προσθήκη',
|
||||||
PAYMENT: 'Πληρωμή',
|
PAYMENT: 'Πληρωμή',
|
||||||
|
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
|
||||||
ORDER_CLOSED: 'Κλείσιμο',
|
ORDER_CLOSED: 'Κλείσιμο',
|
||||||
ORDER_CANCELLED: 'Ακύρωση',
|
ORDER_CANCELLED: 'Ακύρωση',
|
||||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||||
@@ -60,30 +61,47 @@ function AuditTab({ order, waiterMap }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{order.audit_logs.map(log => (
|
{order.audit_logs.map(log => {
|
||||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||||
<div className="shrink-0 mt-0.5">
|
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
const badgeClass = isDuplicate
|
||||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
? 'bg-red-100 text-red-700'
|
||||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
: isPayment ? 'bg-green-100 text-green-700'
|
||||||
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
|
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
|
||||||
'bg-blue-100 text-blue-700'
|
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
|
||||||
}`}>
|
: 'bg-blue-100 text-blue-700'
|
||||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
// Show offline_at (real payment time) when available, else server created_at
|
||||||
</span>
|
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
|
||||||
|
return (
|
||||||
|
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
|
||||||
|
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||||
|
</span>
|
||||||
|
{isDuplicate && (
|
||||||
|
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||||
|
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||||
|
{log.amount != null && (
|
||||||
|
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
|
||||||
|
€{log.amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.payment_method && (
|
||||||
|
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="text-xs text-gray-400">{displayTime}</span>
|
||||||
|
{log.offline_at && (
|
||||||
|
<span className="block text-xs text-orange-400">offline</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
)
|
||||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
})}
|
||||||
{log.amount != null && (
|
|
||||||
<span className="ml-2 font-semibold text-green-700">€{log.amount.toFixed(2)}</span>
|
|
||||||
)}
|
|
||||||
{log.payment_method && (
|
|
||||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -766,6 +766,7 @@ function buildFormFromProduct(product) {
|
|||||||
sort_order: q.sort_order ?? 0,
|
sort_order: q.sort_order ?? 0,
|
||||||
is_favorite: q.is_favorite ?? false,
|
is_favorite: q.is_favorite ?? false,
|
||||||
favorite_sort_order: q.favorite_sort_order ?? 0,
|
favorite_sort_order: q.favorite_sort_order ?? 0,
|
||||||
|
is_compact: q.is_compact ?? false,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
options: product.options?.map(o => ({
|
options: product.options?.map(o => ({
|
||||||
name: o.name,
|
name: o.name,
|
||||||
@@ -906,7 +907,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Options ──
|
// ── Quick Options ──
|
||||||
function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) }
|
function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
|
||||||
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
|
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
|
||||||
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
|
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
|
||||||
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
|
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
|
||||||
@@ -1083,6 +1084,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
|
|||||||
sort_order: i,
|
sort_order: i,
|
||||||
is_favorite: q.is_favorite ?? false,
|
is_favorite: q.is_favorite ?? false,
|
||||||
favorite_sort_order: q.favorite_sort_order ?? 0,
|
favorite_sort_order: q.favorite_sort_order ?? 0,
|
||||||
|
is_compact: q.is_compact ?? false,
|
||||||
})),
|
})),
|
||||||
options: form.options.map(o => ({
|
options: form.options.map(o => ({
|
||||||
name: o.name,
|
name: o.name,
|
||||||
@@ -1346,6 +1348,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
|
|||||||
className="accent-primary-700 w-4 h-4" />
|
className="accent-primary-700 w-4 h-4" />
|
||||||
Πολλαπλά
|
Πολλαπλά
|
||||||
</label>
|
</label>
|
||||||
|
<label title="Μισό πλάτος στο PWA" className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: q.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||||
|
<input type="checkbox" checked={q.is_compact ?? false}
|
||||||
|
onChange={e => setQuickOption(i, 'is_compact', e.target.checked)}
|
||||||
|
className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
|
||||||
|
Compact
|
||||||
|
</label>
|
||||||
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AppInfoTab from './tabs/AppInfoTab'
|
import AppInfoTab from './tabs/AppInfoTab'
|
||||||
import ColoursTab from './tabs/ColoursTab'
|
import ColoursTab from './tabs/ColoursTab'
|
||||||
|
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||||
|
import PrintFontsTab from './tabs/PrintFontsTab'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'app-info', label: 'App Info' },
|
{ key: 'app-info', label: 'App Info' },
|
||||||
{ key: 'colours', label: 'UI Personalization' },
|
{ key: 'colours', label: 'UI Personalization' },
|
||||||
|
{ key: 'print-fonts', label: 'Εκτύπωση' },
|
||||||
|
{ key: 'development', label: 'Development' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -44,8 +48,10 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{activeTab === 'app-info' && <AppInfoTab />}
|
{activeTab === 'app-info' && <AppInfoTab />}
|
||||||
{activeTab === 'colours' && <ColoursTab />}
|
{activeTab === 'colours' && <ColoursTab />}
|
||||||
|
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||||
|
{activeTab === 'development' && <DevelopmentTab />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function FlagDefsSection() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [editingId, setEditingId] = useState(null)
|
const [editingId, setEditingId] = useState(null)
|
||||||
const [editForm, setEditForm] = useState({})
|
const [editForm, setEditForm] = useState({})
|
||||||
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280' })
|
const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280', text_color: null })
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
const { data: flags = [], isLoading } = useQuery({
|
const { data: flags = [], isLoading } = useQuery({
|
||||||
queryKey: ['flag-defs'],
|
queryKey: ['flag-defs'],
|
||||||
@@ -279,7 +279,7 @@ function FlagDefsSection() {
|
|||||||
})
|
})
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (body) => client.post('/api/flags/defs', body),
|
mutationFn: (body) => client.post('/api/flags/defs', body),
|
||||||
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280' }) },
|
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280', text_color: null }) },
|
||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
@@ -294,7 +294,7 @@ function FlagDefsSection() {
|
|||||||
})
|
})
|
||||||
function startEdit(flag) {
|
function startEdit(flag) {
|
||||||
setEditingId(flag.id)
|
setEditingId(flag.id)
|
||||||
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', sort_order: flag.sort_order })
|
setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', text_color: flag.text_color || null, sort_order: flag.sort_order })
|
||||||
}
|
}
|
||||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
|
const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
|
||||||
return (
|
return (
|
||||||
@@ -320,6 +320,13 @@ function FlagDefsSection() {
|
|||||||
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
|
||||||
|
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
|
||||||
|
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
|
<button onClick={() => createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
|
||||||
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
|
style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση</button>
|
||||||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||||||
@@ -342,6 +349,12 @@ function FlagDefsSection() {
|
|||||||
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||||
|
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
|
||||||
|
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
|
<button onClick={() => updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
|
||||||
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓</button>
|
style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓</button>
|
||||||
<button onClick={() => setEditingId(null)}
|
<button onClick={() => setEditingId(null)}
|
||||||
|
|||||||
86
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
86
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, disabled }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
|
style={{
|
||||||
|
width: 44, height: 24, borderRadius: 999, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
background: checked ? '#dc2626' : '#d1d5db',
|
||||||
|
position: 'relative', transition: 'background 150ms', flexShrink: 0, opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 3, left: checked ? 23 : 3,
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevelopmentTab() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }) },
|
||||||
|
onError: () => toast.error('Failed to update setting'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const spoofOn = settings?.['dev.spoof_printing']?.value === 'true'
|
||||||
|
|
||||||
|
function toggleSpoof(val) {
|
||||||
|
mutation.mutate({ key: 'dev.spoof_printing', value: val ? 'true' : 'false' })
|
||||||
|
toast.success(val ? 'Spoof printing ON — printers are silenced' : 'Spoof printing OFF — printers active')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <p style={{ color: '#6b7280', fontSize: 14 }}>Loading…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 560 }}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fef2f2', border: '1px solid #fca5a5',
|
||||||
|
borderRadius: 10, padding: '12px 16px', marginBottom: 24,
|
||||||
|
fontSize: 13, color: '#991b1b',
|
||||||
|
}}>
|
||||||
|
These settings are intended for testing only. Do not leave them enabled in production.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'white', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10, padding: '16px 20px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#111827' }}>
|
||||||
|
Spoof Printer Mode
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 3 }}>
|
||||||
|
All print jobs are silently dropped. Devices behave as if printing succeeded — no errors, nothing printed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={spoofOn} onChange={toggleSpoof} disabled={mutation.isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spoofOn && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: '10px 14px',
|
||||||
|
background: '#fff7ed', border: '1px solid #fed7aa',
|
||||||
|
borderRadius: 8, fontSize: 13, color: '#92400e', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
Spoof mode is active — printers are silenced.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
|
||||||
|
// ── Font option definitions ────────────────────────────────────────────────
|
||||||
|
// 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: 'Ψηλά' },
|
||||||
|
{ size: '32', label: 'Πλατιά' },
|
||||||
|
{ size: '48', label: 'Ψηλά και Πλατιά' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function encodeFont(size, bold, caps) {
|
||||||
|
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
|
||||||
|
}
|
||||||
|
function decodeFont(val) {
|
||||||
|
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 = [
|
||||||
|
{ value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' },
|
||||||
|
{ value: 'equals', label: 'Ίσον ( = )', chars: '===================' },
|
||||||
|
{ value: 'star', label: 'Αστερίσκοι ( * )', chars: '*******************' },
|
||||||
|
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONT_DEFAULTS = {
|
||||||
|
'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 ────────────────────────────────────────────────────────────────
|
||||||
|
const PREVIEW_W = 200
|
||||||
|
const PREVIEW_H = 50
|
||||||
|
|
||||||
|
const sizeStyle = {
|
||||||
|
'0': { fontSize: 13, scaleY: 1, scaleX: 1 },
|
||||||
|
'16': { fontSize: 13, scaleY: 1.9, scaleX: 1 },
|
||||||
|
'32': { fontSize: 13, scaleY: 1, scaleX: 1.9 },
|
||||||
|
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function FontPreview({ size, bold, caps }) {
|
||||||
|
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#1a1a1a', borderRadius: 8,
|
||||||
|
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
color: '#f5f5f5',
|
||||||
|
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||||
|
fontSize: s.fontSize,
|
||||||
|
fontWeight: bold ? 800 : 400,
|
||||||
|
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'block',
|
||||||
|
}}>
|
||||||
|
{caps ? 'SAMPLE' : 'Sample'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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: 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: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
{field.sub && (
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size dropdown */}
|
||||||
|
<select
|
||||||
|
value={size}
|
||||||
|
onChange={handleSize}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', padding: '0 10px', fontSize: 13,
|
||||||
|
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FONT_SIZE_OPTIONS.map(o => (
|
||||||
|
<option key={o.size} value={o.size}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Bold toggle */}
|
||||||
|
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||||
|
|
||||||
|
{/* Caps toggle */}
|
||||||
|
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Divider row ────────────────────────────────────────────────────────────
|
||||||
|
function DividerRow({ value, onChange, isPending }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '14px 20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||||
|
Στυλ Διαχωριστικού
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange('print.divider_style', e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', padding: '0 10px', fontSize: 13,
|
||||||
|
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DIVIDER_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* spacer to align with bold+caps column */}
|
||||||
|
<div style={{ width: 194, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div style={{
|
||||||
|
background: '#1a1a1a', borderRadius: 8,
|
||||||
|
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{value === 'empty'
|
||||||
|
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
|
||||||
|
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
|
||||||
|
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 }) {
|
||||||
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
|
function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
|
||||||
|
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
|
||||||
|
<input value={form.name} onChange={e => set('name', e.target.value)}
|
||||||
|
placeholder="π.χ. Κουζίνα" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
|
||||||
|
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
|
||||||
|
placeholder="10.98.20.25" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
|
||||||
|
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
|
||||||
|
type="number" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
|
||||||
|
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
|
||||||
|
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||||
|
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||||
|
style={btnPrimary}>Αποθήκευση</button>
|
||||||
|
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white',
|
||||||
|
padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%',
|
||||||
|
}
|
||||||
|
const btnPrimary = {
|
||||||
|
height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white',
|
||||||
|
border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}
|
||||||
|
const btnSecondary = {
|
||||||
|
height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151',
|
||||||
|
}
|
||||||
|
const btnDanger = {
|
||||||
|
height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2',
|
||||||
|
background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626',
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) {
|
||||||
|
const [reachable, setReachable] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
client.get('/api/system/status').then(r => {
|
||||||
|
if (cancelled) return
|
||||||
|
const match = r.data.printers?.find(p => p.id === printer.id)
|
||||||
|
if (match) setReachable(match.reachable)
|
||||||
|
}).catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [printer.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
|
||||||
|
opacity: printer.is_active ? 1 : 0.5,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
|
||||||
|
style={{
|
||||||
|
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||||
|
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
|
||||||
|
width: 16, height: 16, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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 }}>
|
||||||
|
{printer.ip_address}:{printer.port}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
|
||||||
|
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
|
||||||
|
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
|
||||||
|
}}>
|
||||||
|
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button onClick={() => onTest(printer.id)} disabled={testPending}
|
||||||
|
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||||
|
Test Print
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onEdit(printer)}
|
||||||
|
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||||
|
Επεξεργασία
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
|
||||||
|
Διαγραφή
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrintersSection() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
|
||||||
|
const { data: printers = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['printers-all'],
|
||||||
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: body => client.post('/api/system/printers', body),
|
||||||
|
onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) },
|
||||||
|
onError: () => toast.error('Σφάλμα δημιουργίας'),
|
||||||
|
})
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body),
|
||||||
|
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) },
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: id => client.delete(`/api/system/printers/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) },
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
const testMut = useMutation({
|
||||||
|
mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||||||
|
onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`),
|
||||||
|
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleToggle(printer) {
|
||||||
|
updateMut.mutate({ id: printer.id, is_active: !printer.is_active })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Διαχείριση εκτυπωτών του συστήματος</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
|
||||||
|
+ Νέος εκτυπωτής
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNew && (
|
||||||
|
<div style={{ padding: '12px 20px' }}>
|
||||||
|
<PrinterForm
|
||||||
|
onSave={form => createMut.mutate(form)}
|
||||||
|
onCancel={() => setShowNew(false)}
|
||||||
|
isPending={createMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||||||
|
{!isLoading && printers.length === 0 && !showNew && (
|
||||||
|
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
|
||||||
|
Δεν υπάρχουν εκτυπωτές ακόμα.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{printers.map(printer => (
|
||||||
|
editingId === printer.id ? (
|
||||||
|
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
|
||||||
|
<PrinterForm
|
||||||
|
initial={printer}
|
||||||
|
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PrinterRow
|
||||||
|
key={printer.id}
|
||||||
|
printer={printer}
|
||||||
|
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
|
||||||
|
onDelete={id => deleteMut.mutate(id)}
|
||||||
|
onTest={id => testMut.mutate(id)}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
testPending={testMut.isPending}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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()
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['pos-settings'],
|
||||||
|
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||||
|
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'] }) },
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] }
|
||||||
|
function handleChange(key, value) { updateMut.mutate({ key, value }) }
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
|
||||||
|
{/* 1. Printers */}
|
||||||
|
<PrintersSection />
|
||||||
|
|
||||||
|
{/* 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_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>
|
||||||
|
|
||||||
|
{/* 4. Divider style */}
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||||
|
</div>
|
||||||
|
<DividerRow
|
||||||
|
value={val('print.divider_style')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
|
||||||
|
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
|
||||||
|
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
simple-pos-system.zip
Normal file
BIN
simple-pos-system.zip
Normal file
Binary file not shown.
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.7tvu7c24jlg"
|
"revision": "0.jqv9du572qo"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
7
waiter_pwa/package-lock.json
generated
7
waiter_pwa/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
|
"dexie": "^4.4.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
@@ -2940,6 +2941,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dexie": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
|
"dexie": "^4.4.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import useAuthStore from './store/authStore'
|
|||||||
import useShiftStore from './store/shiftStore'
|
import useShiftStore from './store/shiftStore'
|
||||||
import useThemeStore from './store/themeStore'
|
import useThemeStore from './store/themeStore'
|
||||||
import useTableColourStore from './store/tableColourStore'
|
import useTableColourStore from './store/tableColourStore'
|
||||||
|
import useConnectionStore from './store/connectionStore'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TableListPage from './pages/TableListPage'
|
import TableListPage from './pages/TableListPage'
|
||||||
import TableDetailPage from './pages/TableDetailPage'
|
import TableDetailPage from './pages/TableDetailPage'
|
||||||
import AddItemsPage from './pages/AddItemsPage'
|
import AddItemsPage from './pages/AddItemsPage'
|
||||||
import OfflinePage from './pages/OfflinePage'
|
import OfflinePage from './pages/OfflinePage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import { NotificationProvider } from './context/NotificationContext'
|
import { NotificationProvider } from './context/NotificationContext'
|
||||||
|
import { SSEProvider } from './context/SSEContext'
|
||||||
|
import ConnectionLostModal from './components/ConnectionLostModal'
|
||||||
|
|
||||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -269,11 +273,18 @@ function AuthRehydrator() {
|
|||||||
|
|
||||||
function OfflineListener() {
|
function OfflineListener() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { token } = useAuthStore()
|
||||||
|
const { status } = useConnectionStore()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => navigate('/offline')
|
function handler() {
|
||||||
|
// If user is logged in, ConnectionLostModal handles it — don't redirect to /offline
|
||||||
|
if (token && status !== 'online') return
|
||||||
|
// Not logged in and server is down → redirect to offline page
|
||||||
|
if (!token) navigate('/offline')
|
||||||
|
}
|
||||||
window.addEventListener('backend-offline', handler)
|
window.addEventListener('backend-offline', handler)
|
||||||
return () => window.removeEventListener('backend-offline', handler)
|
return () => window.removeEventListener('backend-offline', handler)
|
||||||
}, [navigate])
|
}, [navigate, token, status])
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,18 +318,22 @@ export default function App() {
|
|||||||
<ColourLoader />
|
<ColourLoader />
|
||||||
<AuthRehydrator />
|
<AuthRehydrator />
|
||||||
<OfflineListener />
|
<OfflineListener />
|
||||||
<NotificationProvider>
|
<SSEProvider>
|
||||||
<Routes>
|
<NotificationProvider>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<ConnectionLostModal />
|
||||||
<Route path="/offline" element={<OfflinePage />} />
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/tables" element={<TableListPage />} />
|
<Route path="/offline" element={<OfflinePage />} />
|
||||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
<Route path="/tables" element={<TableListPage />} />
|
||||||
</Route>
|
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||||
</Routes>
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</NotificationProvider>
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</NotificationProvider>
|
||||||
|
</SSEProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 prefChoices = preferenceSets.flatMap(ps => {
|
||||||
const choice = selectedPreferences[ps.id]
|
const choice = selectedPreferences[ps.id]
|
||||||
if (!choice) return []
|
if (!choice) return []
|
||||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
|
||||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
||||||
const sharedSub = selectedSharedSubs[ps.id] ?? null
|
const sharedSub = selectedSharedSubs[ps.id] ?? null
|
||||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
})
|
})
|
||||||
|
|
||||||
const optionEntries = selectedOptions.flatMap(o => {
|
const optionEntries = selectedOptions.flatMap(o => {
|
||||||
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }]
|
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0, type: 'extra' }]
|
||||||
const sub = selectedOptionSubs[o.id]
|
const sub = selectedOptionSubs[o.id]
|
||||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
|
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
|
||||||
return entries
|
return entries
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function Row({ selected, onClick, children, right, left, style = {} }) {
|
|||||||
|
|
||||||
// ── Shared: single quick option row ──────────────────────────────────────────
|
// ── Shared: single quick option row ──────────────────────────────────────────
|
||||||
|
|
||||||
function QuickOptionRow({ opt, quickState, setQuickState }) {
|
function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
|
||||||
const qty = quickState[opt.id] || 0
|
const qty = quickState[opt.id] || 0
|
||||||
const selected = qty > 0
|
const selected = qty > 0
|
||||||
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
|
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
|
||||||
@@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
|
<div style={{ fontSize: compact ? 13 : 15, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.name}</div>
|
||||||
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
{opt.price > 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{favorites.map((fav, fi) => {
|
{favorites.map((fav, fi) => {
|
||||||
if (fav.type === 'quick') {
|
if (fav.type === 'quick') {
|
||||||
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} />
|
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} compact={false} />
|
||||||
}
|
}
|
||||||
if (fav.type === 'ingredient') {
|
if (fav.type === 'ingredient') {
|
||||||
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
|
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
|
||||||
@@ -375,9 +375,11 @@ function QuickTab({ product, quickState, setQuickState }) {
|
|||||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
|
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
{quickOptions.map(opt => (
|
{quickOptions.map(opt => (
|
||||||
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} />
|
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
|
||||||
|
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} compact={opt.is_compact} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) {
|
|||||||
{lines.map((l, i) => (
|
{lines.map((l, i) => (
|
||||||
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
|
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
{l.group === 'prefs' ? (
|
||||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
<>
|
||||||
{l.label}
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
|
||||||
</div>
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</div>
|
||||||
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>}
|
</>
|
||||||
|
) : l.group === 'removed' ? (
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
Χωρίς {l.label}
|
||||||
|
</div>
|
||||||
|
) : l.group === 'extras' ? (
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
{l.label}
|
||||||
|
{l.subName && <span> · {l.subName}</span>}
|
||||||
|
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginLeft: 6, fontVariantNumeric: 'tabular-nums' }}>×{l.qty}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||||
|
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
||||||
|
{l.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €</div>}
|
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -600,9 +618,23 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
|||||||
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
||||||
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
|
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
|
||||||
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||||
const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
|
|
||||||
if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null })
|
// Skip if this is entirely the default selection
|
||||||
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
|
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
|
||||||
|
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
|
||||||
|
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
|
||||||
|
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
|
||||||
|
: null
|
||||||
|
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
|
||||||
|
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
|
||||||
|
: null
|
||||||
|
const isFullyDefault = isDefaultChoice
|
||||||
|
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
|
||||||
|
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
|
||||||
|
if (isFullyDefault) { price += delta; return }
|
||||||
|
|
||||||
|
const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
|
||||||
|
lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null })
|
||||||
price += delta
|
price += delta
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
|||||||
if (!sel) return
|
if (!sel) return
|
||||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||||
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
|
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
|
||||||
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null })
|
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null })
|
||||||
price += linePrice
|
price += linePrice
|
||||||
})
|
})
|
||||||
|
|
||||||
ingredients.forEach(ing => {
|
ingredients.forEach(ing => {
|
||||||
if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null })
|
if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
return { summaryLines: lines, totalPrice: price * qty }
|
return { summaryLines: lines, totalPrice: price * qty }
|
||||||
@@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
|||||||
const prefChoices = preferenceSets.flatMap(ps => {
|
const prefChoices = preferenceSets.flatMap(ps => {
|
||||||
const choice = prefs[ps.id]
|
const choice = prefs[ps.id]
|
||||||
if (!choice) return []
|
if (!choice) return []
|
||||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
|
||||||
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
||||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
|
||||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
|
||||||
const sharedSub = sharedSubs[ps.id] ?? null
|
// Don't emit entries that are entirely at their defaults — nothing changed
|
||||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
|
||||||
}
|
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
|
||||||
|
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
|
||||||
|
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
|
||||||
|
: null
|
||||||
|
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
|
||||||
|
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
|
||||||
|
: null
|
||||||
|
const isFullyDefault = isDefaultChoice
|
||||||
|
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
|
||||||
|
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
|
||||||
|
if (isFullyDefault) return []
|
||||||
|
|
||||||
|
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
|
return entries
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -682,8 +727,8 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
|||||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||||
const entries = []
|
const entries = []
|
||||||
for (let i = 0; i < sel.qty; i++) {
|
for (let i = 0; i < sel.qty; i++) {
|
||||||
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
|
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
|
||||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
|
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
})
|
})
|
||||||
@@ -691,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
|||||||
const quickEntries = quickOptions.flatMap(opt => {
|
const quickEntries = quickOptions.flatMap(opt => {
|
||||||
const q = quickState[opt.id] || 0
|
const q = quickState[opt.id] || 0
|
||||||
if (q === 0) return []
|
if (q === 0) return []
|
||||||
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 }))
|
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
|
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
|
||||||
|
|||||||
@@ -1,26 +1,112 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
function fmtPrice(v) {
|
function fmtPrice(v) {
|
||||||
return Number(v).toFixed(2) + ' €'
|
return Number(v).toFixed(2) + ' €'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionIcon({ type }) {
|
||||||
|
const icons = {
|
||||||
|
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
|
||||||
|
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||||
|
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||||
|
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||||
|
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
|
||||||
|
}
|
||||||
|
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse selected_options into grouped sections (same logic as cart) ────────
|
||||||
|
|
||||||
|
function buildSections(item) {
|
||||||
|
const sections = []
|
||||||
|
const opts = (() => {
|
||||||
|
try { return item.selected_options ? JSON.parse(item.selected_options) : [] } catch { return [] }
|
||||||
|
})()
|
||||||
|
const removed = (() => {
|
||||||
|
try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] }
|
||||||
|
})()
|
||||||
|
|
||||||
|
// We don't have product metadata here, so we classify by heuristics:
|
||||||
|
// - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them
|
||||||
|
// - id == null → sub-choice (follows its parent)
|
||||||
|
// Strategy: walk through opts in order, attaching sub-choices to their parent,
|
||||||
|
// then classify parent items: items with a real id that appear multiple times → extra (stacked),
|
||||||
|
// but without product metadata we can't fully distinguish prefs from extras.
|
||||||
|
// We use a simple rule: if an option with id appears only once in the stream → treat as pref
|
||||||
|
// (since extras can be added multiple times). This matches how handleAdd() emits them.
|
||||||
|
|
||||||
|
const prefGroups = [] // { setName: null (unknown), values: [...] }
|
||||||
|
const extraGroups = [] // { id, name, subName, qty }
|
||||||
|
const quickLines = [] // { name, _qty }
|
||||||
|
|
||||||
|
// Count how many times each id appears (extras can be stacked → appear multiple times)
|
||||||
|
const idCount = {}
|
||||||
|
opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 })
|
||||||
|
|
||||||
|
// Single pass: consume each item and its optional following sub (id=null)
|
||||||
|
const consumedAsSubAtIndex = new Set()
|
||||||
|
let i = 0
|
||||||
|
while (i < opts.length) {
|
||||||
|
const o = opts[i]
|
||||||
|
if (consumedAsSubAtIndex.has(i)) { i++; continue }
|
||||||
|
|
||||||
|
if (o.id == null) {
|
||||||
|
// Standalone id=null → quick option
|
||||||
|
const existing = quickLines.find(x => x.name === o.name)
|
||||||
|
if (existing) existing._qty = (existing._qty || 1) + 1
|
||||||
|
else quickLines.push({ name: o.name, _qty: 1 })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// id != null — look ahead for immediate sub
|
||||||
|
let subName = null
|
||||||
|
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||||
|
subName = opts[i + 1].name
|
||||||
|
consumedAsSubAtIndex.add(i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idCount[o.id] > 1) {
|
||||||
|
// Extra — appears multiple times in the list
|
||||||
|
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||||||
|
if (existing) existing.qty++
|
||||||
|
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||||||
|
} else {
|
||||||
|
// Single occurrence → preference choice
|
||||||
|
const value = subName ? `${o.name} · ${subName}` : o.name
|
||||||
|
prefGroups.push({ setName: null, values: [value] })
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||||
|
if (quickLines.length > 0) sections.push({ type: 'quick', lines: quickLines })
|
||||||
|
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||||
|
if (removed.length > 0) sections.push({ type: 'removed', lines: removed.map(n => ({ name: n })) })
|
||||||
|
if (item.notes) sections.push({ type: 'note', lines: [{ name: item.notes }] })
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ItemRow ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
|
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
|
||||||
const isPaid = item.status === 'paid'
|
const isPaid = item.status === 'paid'
|
||||||
const isCancelled = item.status === 'cancelled'
|
const isCancelled = item.status === 'cancelled'
|
||||||
const isStacked = item.quantity > 1
|
|
||||||
|
|
||||||
let opts = []
|
const sections = buildSections(item)
|
||||||
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {}
|
const hasDetails = sections.length > 0
|
||||||
let removed = []
|
const [expanded, setExpanded] = useState(false)
|
||||||
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
|
|
||||||
|
|
||||||
// Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll)
|
// Long-press detection
|
||||||
const pressTimer = useRef(null)
|
const pressTimer = useRef(null)
|
||||||
const didLongPress = useRef(false)
|
const didLongPress = useRef(false)
|
||||||
const touchStartPos = useRef({ x: 0, y: 0 })
|
const touchStartPos = useRef({ x: 0, y: 0 })
|
||||||
|
|
||||||
function handleTouchStart(e) {
|
function handleTouchStart(e) {
|
||||||
if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return
|
if (!selectable || isPaid || isCancelled || !onLongPress) return
|
||||||
didLongPress.current = false
|
didLongPress.current = false
|
||||||
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||||
pressTimer.current = setTimeout(() => {
|
pressTimer.current = setTimeout(() => {
|
||||||
@@ -35,11 +121,9 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
|||||||
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
|
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() { clearTimeout(pressTimer.current) }
|
||||||
clearTimeout(pressTimer.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
function handleBodyClick() {
|
||||||
if (didLongPress.current) { didLongPress.current = false; return }
|
if (didLongPress.current) { didLongPress.current = false; return }
|
||||||
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
||||||
}
|
}
|
||||||
@@ -47,31 +131,115 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
||||||
onClick={handleClick}
|
style={{ userSelect: 'none' }}
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchCancel={handleTouchEnd}
|
|
||||||
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }}
|
|
||||||
>
|
>
|
||||||
<div className="order-item__row">
|
{/* Main row — click to select */}
|
||||||
|
<div
|
||||||
|
onClick={handleBodyClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchEnd}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Selection checkbox */}
|
||||||
{selectable && !isPaid && !isCancelled && (
|
{selectable && !isPaid && !isCancelled && (
|
||||||
<span style={{ marginRight: 8, color: selected ? '#f59e0b' : '#475569' }}>
|
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||||
{selected ? '☑' : '☐'}
|
{selected ? '☑' : '☐'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
|
||||||
|
{/* Name + badges */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||||
|
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||||
|
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||||
|
{!isPaid && !isCancelled && !item.printed && (
|
||||||
|
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Qty + price */}
|
||||||
<span className="order-item__qty">×{item.quantity}</span>
|
<span className="order-item__qty">×{item.quantity}</span>
|
||||||
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
||||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
|
||||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
|
||||||
{!isPaid && !isCancelled && !item.printed && (
|
{hasDetails && (
|
||||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
|
||||||
|
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||||
|
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{opts.map((o, i) => <div key={i} className="order-item__modifier">+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}</div>)}
|
|
||||||
{removed.map((r, i) => <div key={i} className="order-item__modifier">- {r}</div>)}
|
{/* Expanded details */}
|
||||||
{item.notes && <div className="order-item__modifier">📝 {item.notes}</div>}
|
{expanded && hasDetails && (
|
||||||
|
<div style={{ paddingBottom: 8 }}>
|
||||||
|
{sections.map((sec, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||||
|
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||||
|
<SectionIcon type="prefs" />
|
||||||
|
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||||
|
{line.setName && (
|
||||||
|
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="quick" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||||
|
{line.name}
|
||||||
|
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="extras" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||||
|
{line.name}
|
||||||
|
{line.subName && <span> · {line.subName}</span>}
|
||||||
|
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="removed" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||||
|
<SectionIcon type="note" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,11 @@ function buildSections(parent, subcategories, directProducts) {
|
|||||||
return sections.sort((a, b) => a.sort_order - b.sort_order)
|
return sections.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductPicker({ categories, products, onAdd }) {
|
export default function ProductPicker({ categories, products, onAdd, viewAllOpen, setViewAllOpen }) {
|
||||||
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
||||||
const initialCatId = topLevel[0]?.id ?? null
|
const initialCatId = topLevel[0]?.id ?? null
|
||||||
const [activeCat, setActiveCat] = useState(initialCatId)
|
const [activeCat, setActiveCat] = useState(initialCatId)
|
||||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
|
||||||
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
|
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
|
||||||
const [expandedSubs, setExpandedSubs] = useState(() => {
|
const [expandedSubs, setExpandedSubs] = useState(() => {
|
||||||
if (!initialCatId) return {}
|
if (!initialCatId) return {}
|
||||||
@@ -125,18 +124,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
|||||||
return (
|
return (
|
||||||
<div className="product-picker">
|
<div className="product-picker">
|
||||||
<div className="category-tabs">
|
<div className="category-tabs">
|
||||||
<div className="category-tabs__sticky">
|
|
||||||
<button
|
|
||||||
className="cat-tab cat-tab--viewall"
|
|
||||||
onClick={() => setViewAllOpen(true)}
|
|
||||||
title="Εμφάνιση όλων"
|
|
||||||
>
|
|
||||||
<CategoriesIcon width="20" height="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="category-tabs__scroll-wrap">
|
<div className="category-tabs__scroll-wrap">
|
||||||
<div className="category-tabs__fade" />
|
|
||||||
<div className="category-tabs__scroll">
|
<div className="category-tabs__scroll">
|
||||||
{topLevel.map(cat => {
|
{topLevel.map(cat => {
|
||||||
const isActive = activeCat === cat.id
|
const isActive = activeCat === cat.id
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useRef, useState } from 'react'
|
|||||||
import useThemeStore from '../store/themeStore'
|
import useThemeStore from '../store/themeStore'
|
||||||
import useTableColourStore from '../store/tableColourStore'
|
import useTableColourStore from '../store/tableColourStore'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
free: 'ΕΛΕΥΘΕΡΟ',
|
free: 'ΕΛΕΥΘΕΡΟ',
|
||||||
open: 'ΑΝΟΙΧΤΟ',
|
open: 'ΑΝΟΙΧΤΟ',
|
||||||
@@ -13,7 +15,555 @@ const STATUS_LABELS = {
|
|||||||
const DRAG_THRESHOLD = 8
|
const DRAG_THRESHOLD = 8
|
||||||
const HOLD_MS = 480
|
const HOLD_MS = 480
|
||||||
|
|
||||||
export default function TableCard({ table, order, isMine, flags = [], groupName = '', onClick, onLongPress }) {
|
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AVATAR_PALETTE = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a']
|
||||||
|
|
||||||
|
function avatarColor(name = '') {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||||
|
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaiterAvatar({ waiter, size = 22, ring }) {
|
||||||
|
const displayName = waiter.nickname || waiter.full_name || waiter.username || '?'
|
||||||
|
const initials = displayName.trim().split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
|
||||||
|
const ringStyle = ring ? { boxShadow: `0 0 0 2px ${ring}` } : {}
|
||||||
|
|
||||||
|
if (waiter.avatar_url) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={API_URL + waiter.avatar_url}
|
||||||
|
alt={displayName}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
objectFit: 'cover', flexShrink: 0,
|
||||||
|
...ringStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: avatarColor(displayName),
|
||||||
|
color: 'white', fontSize: size * 0.4, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
...ringStyle,
|
||||||
|
}}>{initials}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders [icon] Name, [icon] Name inline. Falls back to icons + "X Waiters" if they don't fit
|
||||||
|
// (we approximate "don't fit" as > 2 waiters for the compact footer height).
|
||||||
|
function WaiterRow({ waiters, size = 22, cfg }) {
|
||||||
|
if (!waiters?.length) return null
|
||||||
|
const textColor = cfg.nameText
|
||||||
|
|
||||||
|
// ≤ 2 waiters: show icon + name pairs
|
||||||
|
if (waiters.length <= 2) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'nowrap', overflow: 'hidden', minWidth: 0 }}>
|
||||||
|
{waiters.map((w, i) => {
|
||||||
|
const name = w.nickname || w.full_name || w.username || '?'
|
||||||
|
return (
|
||||||
|
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
{i > 0 && <span style={{ color: textColor, opacity: 0.3, fontSize: 14, flexShrink: 0 }}>·</span>}
|
||||||
|
<WaiterAvatar waiter={w} size={size} />
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.85,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// > 2 waiters: icons only + "X Waiters" label
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{waiters.slice(0, 3).map((w, i) => (
|
||||||
|
<div key={w.id} style={{ marginLeft: i === 0 ? 0 : -(size * 0.28) }}>
|
||||||
|
<WaiterAvatar waiter={w} size={size} ring={cfg.cardBg} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{waiters.length > 3 && (
|
||||||
|
<div style={{
|
||||||
|
marginLeft: -(size * 0.28), height: size, padding: '0 6px',
|
||||||
|
borderRadius: size, background: `${cfg.nameText}20`,
|
||||||
|
color: cfg.nameText, fontSize: 10, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}>+{waiters.length - 3}</div>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.7, marginLeft: 4 }}>
|
||||||
|
{waiters.length} σερβιτόροι
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status pill ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatusPill({ label, badgeBg, badgeText, small }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
height: small ? 18 : 20,
|
||||||
|
padding: small ? '0 6px' : '0 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: badgeBg,
|
||||||
|
color: badgeText,
|
||||||
|
fontSize: small ? 9 : 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>{label}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Flag dot ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FlagDot({ flag, size = 22 }) {
|
||||||
|
const textColor = flag.text_color || '#ffffff'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={flag.name}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: flag.color || '#6295F3',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.55,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flag.emoji || '🏷️'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Flag overflow row: show up to maxShow dots, then +N bubble ───────────────
|
||||||
|
|
||||||
|
function FlagDots({ flags, size, maxShow }) {
|
||||||
|
if (!flags.length) return null
|
||||||
|
const visible = flags.slice(0, maxShow)
|
||||||
|
const overflow = flags.length - maxShow
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||||
|
{visible.map(f => <FlagDot key={f.id} flag={f} size={size} />)}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,0,0,0.18)',
|
||||||
|
color: '#fff', fontSize: size * 0.44, fontWeight: 800,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>+{overflow}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Flag chip (icon + label) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FlagChip({ flag }) {
|
||||||
|
const textColor = flag.text_color || '#ffffff'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={flag.name}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
height: 26, padding: '0 9px',
|
||||||
|
borderRadius: 13,
|
||||||
|
background: flag.color || '#6295F3',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, lineHeight: 1 }}>{flag.emoji || '🏷️'}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: textColor, whiteSpace: 'nowrap' }}>
|
||||||
|
{flag.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Amount display ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Amount({ value, size = 22, color }) {
|
||||||
|
const s = Number(value || 0).toFixed(2)
|
||||||
|
const [whole, cents] = s.split('.')
|
||||||
|
const isNum = typeof size === 'number'
|
||||||
|
const centsSize = isNum ? size * 0.56 : `calc(${size} * 0.56)`
|
||||||
|
return (
|
||||||
|
<div style={{ lineHeight: 1, color: color || 'inherit' }}>
|
||||||
|
<span style={{ fontSize: size, fontWeight: 800, letterSpacing: -0.5 }}>{whole}</span>
|
||||||
|
<span style={{ fontSize: centsSize, fontWeight: 800, opacity: 0.8 }}>.{cents}€</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card variants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, status bottom.
|
||||||
|
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', aspectRatio: '1 / 1.05',
|
||||||
|
background: cfg.cardBg, borderRadius: 14,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
padding: 8,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||||
|
}}>
|
||||||
|
{/* top strip: badges up to 2, then +N */}
|
||||||
|
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<FlagDots flags={flags} size={16} maxShow={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* center: name */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
|
||||||
|
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* bottom strip: status */}
|
||||||
|
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 7, fontWeight: 800, letterSpacing: 0.3,
|
||||||
|
color: cfg.badgeText, textTransform: 'uppercase',
|
||||||
|
background: cfg.badgeBg, borderRadius: 3,
|
||||||
|
padding: '1px 4px', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{STATUS_LABELS[statusKey]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right.
|
||||||
|
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: 64,
|
||||||
|
background: cfg.cardBg, borderRadius: 14,
|
||||||
|
padding: '10px 12px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
gap: 10, overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
|
||||||
|
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<FlagDots flags={flags} size={18} maxShow={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
|
||||||
|
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||||
|
const isFree = !order
|
||||||
|
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||||
|
const showAmount = !isFree
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', minHeight: 116,
|
||||||
|
background: cfg.cardBg, borderRadius: 16,
|
||||||
|
padding: '12px 12px 12px',
|
||||||
|
display: 'flex', gap: 8, overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||||
|
}}>
|
||||||
|
{/* left column */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
|
||||||
|
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</span>
|
||||||
|
<div style={{ marginTop: 5 }}>
|
||||||
|
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
|
||||||
|
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right column: flags — show 2, then +N */}
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column-reverse',
|
||||||
|
gap: 4, alignItems: 'flex-end', justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<FlagDots flags={flags} size={26} maxShow={2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x1 — full width horizontal. Name + amount left-center, badges (up to 3 + +N) + status right.
|
||||||
|
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||||
|
const isFree = !order
|
||||||
|
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||||
|
const showAmount = !isFree
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: 68,
|
||||||
|
background: cfg.cardBg, borderRadius: 14,
|
||||||
|
padding: '12px 14px',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||||
|
}}>
|
||||||
|
{/* name */}
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
|
||||||
|
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* separator dot */}
|
||||||
|
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
|
||||||
|
|
||||||
|
{/* amount */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* flags up to 3 + +N */}
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<FlagDots flags={flags} size={24} maxShow={3} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* status */}
|
||||||
|
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x2 — full width, tall. One main row: name+zone left, status center, amount+flags right. Flag chips below. Waiter footer.
|
||||||
|
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||||||
|
const isFree = !order
|
||||||
|
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||||
|
const showAmount = !isFree
|
||||||
|
const showWaiters = !isFree && waiterObjects.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
background: cfg.cardBg, borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* main body */}
|
||||||
|
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
{/* left: name + zone */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 800, fontSize: 'clamp(30px, 7vw, 44px)',
|
||||||
|
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</div>
|
||||||
|
{groupName && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||||||
|
color: cfg.nameText, opacity: 0.6,
|
||||||
|
textTransform: 'uppercase', marginTop: 3,
|
||||||
|
}}>
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
|
||||||
|
<div style={{ paddingTop: 4, flexShrink: 0 }}>
|
||||||
|
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right: amount — top-aligned */}
|
||||||
|
{showAmount && (
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* flag chips row — right-aligned */}
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{flags.slice(0, 4).map(f => <FlagChip key={f.id} flag={f} />)}
|
||||||
|
{flags.length > 4 && (
|
||||||
|
<div style={{
|
||||||
|
height: 26, padding: '0 9px', borderRadius: 13,
|
||||||
|
background: 'rgba(0,0,0,0.18)', color: '#fff',
|
||||||
|
fontSize: 11, fontWeight: 800,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}>+{flags.length - 4}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer: waiters */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: `1px solid ${cfg.nameText}22`,
|
||||||
|
padding: '10px 14px', minHeight: 40,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
{showWaiters
|
||||||
|
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
|
||||||
|
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: order items list. Footer: waiters.
|
||||||
|
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||||||
|
const isFree = !order
|
||||||
|
const activeItems = order?.items?.filter(i => i.status === 'active') ?? []
|
||||||
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
const showWaiters = !isFree && waiterObjects.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
background: cfg.cardBg, borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
{/* left column: name, zone, amount, status, flags */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 800, fontSize: 'clamp(28px, 6vw, 40px)',
|
||||||
|
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||||||
|
}}>
|
||||||
|
{table.label || `T${table.number}`}
|
||||||
|
</div>
|
||||||
|
{groupName && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||||||
|
color: cfg.nameText, opacity: 0.6,
|
||||||
|
textTransform: 'uppercase', marginTop: 3,
|
||||||
|
}}>
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
<FlagDots flags={flags} size={22} maxShow={3} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* divider */}
|
||||||
|
<div style={{ width: 1, background: `${cfg.nameText}20`, alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* right column: order items */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
{isFree ? (
|
||||||
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Ελεύθερο</span>
|
||||||
|
</div>
|
||||||
|
) : activeItems.length === 0 ? (
|
||||||
|
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Κανένα είδος</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
||||||
|
{activeItems.slice(0, 7).map(item => (
|
||||||
|
<div key={item.id} style={{ display: 'flex', alignItems: 'baseline', gap: 5, overflow: 'hidden', minWidth: 0 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 700, color: cfg.nameText,
|
||||||
|
background: `${cfg.nameText}18`, borderRadius: 3,
|
||||||
|
padding: '1px 5px', flexShrink: 0,
|
||||||
|
}}>{item.quantity}×</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 500, color: cfg.nameText,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
|
||||||
|
}}>{item.product?.name || `#${item.product_id}`}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: cfg.nameText, opacity: 0.7, flexShrink: 0 }}>
|
||||||
|
{(item.unit_price * item.quantity).toFixed(2)}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activeItems.length > 7 && (
|
||||||
|
<div style={{ fontSize: 11, color: cfg.nameText, opacity: 0.5, marginTop: 2 }}>
|
||||||
|
+{activeItems.length - 7} ακόμα…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer: waiters */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: `1px solid ${cfg.nameText}22`,
|
||||||
|
padding: '10px 14px', minHeight: 38,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
{showWaiters
|
||||||
|
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
|
||||||
|
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TableCard({
|
||||||
|
table,
|
||||||
|
order,
|
||||||
|
isMine,
|
||||||
|
flags = [],
|
||||||
|
groupName = '',
|
||||||
|
waiterObjects = [],
|
||||||
|
density = '2x2',
|
||||||
|
onClick,
|
||||||
|
onLongPress,
|
||||||
|
}) {
|
||||||
const holdTimer = useRef(null)
|
const holdTimer = useRef(null)
|
||||||
const startPos = useRef({ x: 0, y: 0 })
|
const startPos = useRef({ x: 0, y: 0 })
|
||||||
const didFire = useRef(false)
|
const didFire = useRef(false)
|
||||||
@@ -31,8 +581,6 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
|||||||
const mode = dark ? 'dark' : 'light'
|
const mode = dark ? 'dark' : 'light'
|
||||||
const cfg = colours[mode][statusKey]
|
const cfg = colours[mode][statusKey]
|
||||||
|
|
||||||
const displayName = table.label || `T${table.number}`
|
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
clearTimeout(holdTimer.current)
|
clearTimeout(holdTimer.current)
|
||||||
holdTimer.current = null
|
holdTimer.current = null
|
||||||
@@ -57,10 +605,7 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
|||||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() { cancel(); setShowTip(false) }
|
||||||
cancel()
|
|
||||||
setShowTip(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseDown(e) {
|
function onMouseDown(e) {
|
||||||
startPos.current = { x: e.clientX, y: e.clientY }
|
startPos.current = { x: e.clientX, y: e.clientY }
|
||||||
@@ -85,11 +630,21 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
|||||||
onClick?.()
|
onClick?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
|
||||||
|
|
||||||
|
const CardComponent = {
|
||||||
|
'1x1': Card1x1,
|
||||||
|
'2x1': Card2x1,
|
||||||
|
'2x2': Card2x2,
|
||||||
|
'4x1': Card4x1,
|
||||||
|
'4x2': Card4x2,
|
||||||
|
'4x3': Card4x3,
|
||||||
|
}[density] || Card2x2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
className="table-card-v2"
|
style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||||
style={{ background: cfg.cardBg }}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
@@ -99,89 +654,16 @@ export default function TableCard({ table, order, isMine, flags = [], groupName
|
|||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{/* Top-left: table name + area */}
|
<CardComponent {...cardProps} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '65%' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 'clamp(22px, 5.5vw, 36px)',
|
|
||||||
fontWeight: 800,
|
|
||||||
lineHeight: 1.05,
|
|
||||||
color: cfg.nameText,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}>
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
{groupName && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
color: cfg.nameText + '80',
|
|
||||||
marginTop: 1,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
{groupName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom-left: status badge */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: 11, left: 11,
|
|
||||||
background: cfg.badgeBg,
|
|
||||||
borderRadius: 5,
|
|
||||||
padding: '2px 8px',
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
color: cfg.badgeText,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{STATUS_LABELS[statusKey]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom-right: flag circles, stacked, up to 3 visible */}
|
|
||||||
{flags.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: 8, right: 10,
|
|
||||||
display: 'flex', flexDirection: 'column-reverse', gap: 4,
|
|
||||||
}}>
|
|
||||||
{flags.slice(0, 3).map(f => (
|
|
||||||
<div key={f.id} style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%',
|
|
||||||
background: 'rgba(98,149,243,0.9)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 14,
|
|
||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
||||||
}}>
|
|
||||||
{f.emoji || '🏷️'}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{flags.length > 3 && (
|
|
||||||
<div style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%',
|
|
||||||
background: 'rgba(98,149,243,0.9)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 10, fontWeight: 700, color: '#fff',
|
|
||||||
}}>
|
|
||||||
+{flags.length - 3}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
|
|
||||||
{showTip && flags.length > 0 && (
|
{showTip && flags.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
||||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||||
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
||||||
boxShadow: '0 4px 16px var(--shadow)',
|
boxShadow: '0 4px 16px var(--shadow)',
|
||||||
minWidth: 160,
|
minWidth: 160, pointerEvents: 'none',
|
||||||
pointerEvents: 'none',
|
|
||||||
}}>
|
}}>
|
||||||
{flags.map(f => (
|
{flags.map(f => (
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ export default function UserMenu() {
|
|||||||
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
|
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* ── Settings ──────────────────────────────────────── */}
|
||||||
|
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
|
||||||
|
<span className="user-menu-item__icon">⚙️</span>
|
||||||
|
<span>Ρυθμίσεις</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="user-menu-divider" />
|
<div className="user-menu-divider" />
|
||||||
|
|
||||||
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
|
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ function NotificationBanner({ message, onAck }) {
|
|||||||
|
|
||||||
export function NotificationProvider({ children }) {
|
export function NotificationProvider({ children }) {
|
||||||
const { token, user } = useAuthStore()
|
const { token, user } = useAuthStore()
|
||||||
const [pendingMessages, setPendingMessages] = useState([]) // unacked
|
const [pendingMessages, setPendingMessages] = useState([])
|
||||||
const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history)
|
const [recentMessages, setRecentMessages] = useState([])
|
||||||
const pollRef = useRef(null)
|
|
||||||
|
|
||||||
const fetchUnread = useCallback(async () => {
|
const fetchUnread = useCallback(async () => {
|
||||||
if (!token || !user) return
|
if (!token || !user) return
|
||||||
@@ -72,14 +71,62 @@ export function NotificationProvider({ children }) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}, [token, user?.id])
|
}, [token, user?.id])
|
||||||
|
|
||||||
|
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !user) return
|
if (!token || !user) return
|
||||||
fetchUnread()
|
fetchUnread()
|
||||||
fetchRecent()
|
fetchRecent()
|
||||||
pollRef.current = setInterval(fetchUnread, 2000)
|
const id = setInterval(fetchUnread, 5000)
|
||||||
return () => clearInterval(pollRef.current)
|
return () => clearInterval(id)
|
||||||
}, [token, user?.id])
|
}, [token, user?.id])
|
||||||
|
|
||||||
|
// SSE message_sent events → add to pending without polling
|
||||||
|
useEffect(() => {
|
||||||
|
function onSSEEvent(e) {
|
||||||
|
const { type, data } = e.detail
|
||||||
|
if (type !== 'message_sent') return
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
// Check if this message targets us (empty = broadcast)
|
||||||
|
const targets = data.target_waiter_ids || []
|
||||||
|
if (targets.length > 0 && !targets.includes(user.id)) return
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
id: data.id,
|
||||||
|
sender_id: data.sender_id,
|
||||||
|
sender_name: data.sender_name,
|
||||||
|
body: data.body,
|
||||||
|
table_ids: data.table_ids,
|
||||||
|
created_at: data.created_at,
|
||||||
|
acked_by: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [msg, ...prev]
|
||||||
|
})
|
||||||
|
setRecentMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [msg, ...prev].slice(0, 10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('sse-event', onSSEEvent)
|
||||||
|
return () => window.removeEventListener('sse-event', onSSEEvent)
|
||||||
|
}, [user?.id])
|
||||||
|
|
||||||
|
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
|
||||||
|
useEffect(() => {
|
||||||
|
function onSSEConnect() {
|
||||||
|
fetchUnread()
|
||||||
|
fetchRecent()
|
||||||
|
}
|
||||||
|
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
|
||||||
|
// through the backend-coming-back-online signal that SSEProvider dispatches
|
||||||
|
window.addEventListener('sse-reconnected', onSSEConnect)
|
||||||
|
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
|
||||||
|
}, [fetchUnread, fetchRecent])
|
||||||
|
|
||||||
async function ackMessage(messageId) {
|
async function ackMessage(messageId) {
|
||||||
try {
|
try {
|
||||||
await client.post(`/api/messages/${messageId}/ack`)
|
await client.post(`/api/messages/${messageId}/ack`)
|
||||||
@@ -91,7 +138,7 @@ export function NotificationProvider({ children }) {
|
|||||||
const unreadCount = pendingMessages.length
|
const unreadCount = pendingMessages.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}>
|
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Floating banner stack (max 3 visible) */}
|
{/* Floating banner stack (max 3 visible) */}
|
||||||
|
|||||||
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); }
|
.text-input:focus { border-color: var(--accent); }
|
||||||
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
|
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
|
||||||
|
|
||||||
/* ── Filter Tabs ─────────────────────────────────────────── */
|
/* ── Zone Tab Bar (replaces old filter-tabs) ─────────────── */
|
||||||
.filter-tabs {
|
.zone-tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.filter-tab {
|
.zone-tab-bar::-webkit-scrollbar { display: none; }
|
||||||
flex: 1;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: var(--bg2);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.filter-tab--active { background: var(--accent); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Table Grid ──────────────────────────────────────────── */
|
/* ── Table Grid — density-driven via inline style ─────────── */
|
||||||
.table-grid {
|
/* Cards use inline styles per density, grid columns come from JS */
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
.table-card-v2 {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 12px 12px 48px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 116px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: transform 0.12s;
|
|
||||||
box-shadow: 0 2px 10px var(--shadow);
|
|
||||||
}
|
|
||||||
.table-card-v2:active { transform: scale(0.96); }
|
.table-card-v2:active { transform: scale(0.96); }
|
||||||
|
|
||||||
/* ── FAB ─────────────────────────────────────────────────── */
|
|
||||||
.fab {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-size: 24px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 16px var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cart badge ──────────────────────────────────────────── */
|
/* ── Cart badge ──────────────────────────────────────────── */
|
||||||
.cart-badge {
|
.cart-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -315,20 +268,10 @@ html, body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.category-tabs__fade {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 40px;
|
|
||||||
background: linear-gradient(to right, var(--bg2) 40%, transparent 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.category-tabs__scroll {
|
.category-tabs__scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px 10px 36px;
|
padding: 10px 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export default function AddItemsPage() {
|
|||||||
const [printAck, setPrintAck] = useState(null)
|
const [printAck, setPrintAck] = useState(null)
|
||||||
const [cartOpen, setCartOpen] = useState(false)
|
const [cartOpen, setCartOpen] = useState(false)
|
||||||
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
||||||
|
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -31,6 +35,20 @@ export default function AddItemsPage() {
|
|||||||
setCategories(catRes.data)
|
setCategories(catRes.data)
|
||||||
setProducts(prodRes.data)
|
setProducts(prodRes.data)
|
||||||
setOrderId(statusRes.data.active_order_id)
|
setOrderId(statusRes.data.active_order_id)
|
||||||
|
|
||||||
|
// Pre-populate cart from "order again" if present
|
||||||
|
const stored = sessionStorage.getItem('orderAgainItems')
|
||||||
|
if (stored) {
|
||||||
|
sessionStorage.removeItem('orderAgainItems')
|
||||||
|
try {
|
||||||
|
const items = JSON.parse(stored)
|
||||||
|
const initialCart = items.map(it => ({
|
||||||
|
...it,
|
||||||
|
_key: Date.now() + Math.random(),
|
||||||
|
}))
|
||||||
|
setCart(initialCart)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [tableId])
|
}, [tableId])
|
||||||
@@ -139,28 +157,65 @@ export default function AddItemsPage() {
|
|||||||
const sections = []
|
const sections = []
|
||||||
|
|
||||||
if (item.selected_options?.length) {
|
if (item.selected_options?.length) {
|
||||||
// Group consecutive options into logical sections by type
|
|
||||||
// Prefs: options that match a preference choice (have a real id matching preference_sets choices)
|
|
||||||
const prefIds = new Set(
|
const prefIds = new Set(
|
||||||
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
|
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
|
||||||
)
|
)
|
||||||
|
// Build a map: prefChoiceId → preference set name
|
||||||
|
const prefSetByChoiceId = {}
|
||||||
|
;(product?.preference_sets || []).forEach(ps => {
|
||||||
|
ps.choices.forEach(c => { prefSetByChoiceId[c.id] = ps.name })
|
||||||
|
})
|
||||||
const quickNames = new Set((product?.quick_options || []).map(o => o.name))
|
const quickNames = new Set((product?.quick_options || []).map(o => o.name))
|
||||||
const extraIds = new Set((product?.options || []).map(o => o.id))
|
const extraIds = new Set((product?.options || []).map(o => o.id))
|
||||||
|
|
||||||
const prefLines = []
|
// Group prefs: { prefSetName, choiceName, subName }
|
||||||
|
const prefGroups = []
|
||||||
|
// Group extras: { name, subName, qty } — one entry per unique (id)
|
||||||
|
const extraGroups = []
|
||||||
const quickLines = []
|
const quickLines = []
|
||||||
const extraLines = []
|
|
||||||
|
|
||||||
item.selected_options.forEach(o => {
|
let i = 0
|
||||||
if (prefIds.has(o.id)) prefLines.push(o)
|
const opts = item.selected_options
|
||||||
else if (o.id != null && extraIds.has(o.id)) extraLines.push(o)
|
while (i < opts.length) {
|
||||||
else if (quickNames.has(o.name)) quickLines.push(o)
|
const o = opts[i]
|
||||||
else if (o.id == null) {
|
if (prefIds.has(o.id)) {
|
||||||
// sub-choice — attach to last extra or pref line
|
// Collect sub immediately following (id === null)
|
||||||
if (extraLines.length > 0) extraLines.push({ ...o, _sub: true })
|
const setName = prefSetByChoiceId[o.id] ?? ''
|
||||||
else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true })
|
let subName = null
|
||||||
|
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||||
|
subName = opts[i + 1].name
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Merge into existing prefGroup for same setName, or create new
|
||||||
|
const existing = prefGroups.find(g => g.setName === setName)
|
||||||
|
if (existing) {
|
||||||
|
// multiple choices from same set (shouldn't normally happen, but handle gracefully)
|
||||||
|
existing.values.push(subName ? `${o.name} · ${subName}` : o.name)
|
||||||
|
} else {
|
||||||
|
prefGroups.push({ setName, values: [subName ? `${o.name} · ${subName}` : o.name] })
|
||||||
|
}
|
||||||
|
} else if (o.id != null && extraIds.has(o.id)) {
|
||||||
|
// Collect sub immediately following
|
||||||
|
let subName = null
|
||||||
|
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||||
|
subName = opts[i + 1].name
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
// Merge duplicates
|
||||||
|
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||||||
|
if (existing) existing.qty++
|
||||||
|
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||||||
|
} else if (quickNames.has(o.name)) {
|
||||||
|
quickLines.push(o)
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
// Deduplicate quick lines: multiple entries of same name → single entry with qty
|
// Deduplicate quick lines: multiple entries of same name → single entry with qty
|
||||||
const quickDeduped = []
|
const quickDeduped = []
|
||||||
@@ -170,9 +225,9 @@ export default function AddItemsPage() {
|
|||||||
else quickDeduped.push({ ...o, _qty: 1 })
|
else quickDeduped.push({ ...o, _qty: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines })
|
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||||
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
|
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
|
||||||
if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines })
|
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.removed_ingredients?.length) {
|
if (item.removed_ingredients?.length) {
|
||||||
@@ -196,7 +251,7 @@ export default function AddItemsPage() {
|
|||||||
else lines.push(o.name)
|
else lines.push(o.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`)
|
if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`)
|
||||||
if (item.notes) lines.push(item.notes)
|
if (item.notes) lines.push(item.notes)
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@@ -259,31 +314,55 @@ export default function AddItemsPage() {
|
|||||||
<header className="top-bar">
|
<header className="top-bar">
|
||||||
<button className="icon-btn" onClick={handleBack}>←</button>
|
<button className="icon-btn" onClick={handleBack}>←</button>
|
||||||
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
||||||
{/* Cart icon with badge — opens side drawer */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<button
|
{/* Search button */}
|
||||||
className="icon-btn"
|
<button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
|
||||||
style={{ position: 'relative' }}
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
onClick={() => setCartOpen(true)}
|
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
|
||||||
>
|
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
</svg>
|
||||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
</button>
|
||||||
</svg>
|
{/* Categories button */}
|
||||||
{cart.length > 0 && (
|
<button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
|
||||||
<span style={{
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
position: 'absolute', top: -2, right: -2,
|
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
minWidth: 18, height: 18, borderRadius: 9,
|
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
background: 'var(--accent)', color: 'var(--accent-fg)',
|
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
fontSize: 11, fontWeight: 800,
|
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
</svg>
|
||||||
padding: '0 4px',
|
</button>
|
||||||
}}>{cart.length}</span>
|
{/* Cart button with badge */}
|
||||||
)}
|
<button
|
||||||
</button>
|
className="icon-btn"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
onClick={() => setCartOpen(true)}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{cart.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: -2, right: -2,
|
||||||
|
minWidth: 18, height: 18, borderRadius: 9,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-fg)',
|
||||||
|
fontSize: 11, fontWeight: 800,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '0 4px',
|
||||||
|
}}>{cart.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Product picker takes all remaining space */}
|
{/* Product picker takes all remaining space */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<ProductPicker categories={categories} products={products} onAdd={addToCart} />
|
<ProductPicker
|
||||||
|
categories={categories}
|
||||||
|
products={products}
|
||||||
|
onAdd={addToCart}
|
||||||
|
viewAllOpen={viewAllOpen}
|
||||||
|
setViewAllOpen={setViewAllOpen}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
|
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
|
||||||
@@ -331,17 +410,12 @@ export default function AddItemsPage() {
|
|||||||
className="btn btn--primary btn--lg"
|
className="btn btn--primary btn--lg"
|
||||||
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
||||||
onClick={sendOrder}
|
onClick={sendOrder}
|
||||||
disabled={cart.length === 0 || sending}
|
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||||
>
|
>
|
||||||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
|
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
|
||||||
{printAck?.allOk && (
|
|
||||||
<div style={{ marginTop: 8, background: '#14532d', border: '1px solid #22c55e', borderRadius: 10, padding: '8px 14px', color: '#86efac', fontWeight: 600, fontSize: 13, textAlign: 'center' }}>
|
|
||||||
✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
||||||
@@ -414,7 +488,7 @@ export default function AddItemsPage() {
|
|||||||
className="btn btn--primary btn--lg"
|
className="btn btn--primary btn--lg"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onClick={sendOrder}
|
onClick={sendOrder}
|
||||||
disabled={cart.length === 0 || sending}
|
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||||
>
|
>
|
||||||
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
||||||
</button>
|
</button>
|
||||||
@@ -432,6 +506,46 @@ export default function AddItemsPage() {
|
|||||||
initialState={editItem.drawerState}
|
initialState={editItem.drawerState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Search modal ─────────────────────────────────────────────────────── */}
|
||||||
|
{searchOpen && (
|
||||||
|
<SearchModal
|
||||||
|
products={products}
|
||||||
|
query={searchQuery}
|
||||||
|
setQuery={setSearchQuery}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onAdd={item => { addToCart(item); setSearchOpen(false) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full-screen success overlay — blocks all interaction while navigating */}
|
||||||
|
{printAck?.allOk && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.72)',
|
||||||
|
animation: 'fadeInOverlay 180ms ease',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#14532d', border: '2px solid #22c55e',
|
||||||
|
borderRadius: 20, padding: '36px 48px',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||||||
|
animation: 'popIn 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||||||
|
}}>
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="11" stroke="#22c55e" strokeWidth="2"/>
|
||||||
|
<path d="M7 12.5l3.5 3.5 6.5-7" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ color: '#86efac', fontWeight: 700, fontSize: 18, letterSpacing: 0.3 }}>
|
||||||
|
Εκτυπώθηκε Επιτυχώς
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeInOverlay { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes popIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -502,29 +616,48 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
|
|||||||
<div style={{ paddingBottom: 10 }}>
|
<div style={{ paddingBottom: 10 }}>
|
||||||
{sections.map((sec, si) => (
|
{sections.map((sec, si) => (
|
||||||
<div key={si}>
|
<div key={si}>
|
||||||
{/* Divider between sections */}
|
|
||||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||||
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{sec.lines.map((line, li) => (
|
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||||
<SectionIcon type={line._sub ? 'quick' : sec.type} />
|
<SectionIcon type="prefs" />
|
||||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1 }}>
|
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||||
{sec.type === 'note' ? line.name : (
|
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||||
<>
|
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||||
{line.name}
|
|
||||||
{line._qty > 1 && (
|
|
||||||
<span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>
|
|
||||||
)}
|
|
||||||
{line.price_delta !== 0 && line.price_delta != null && (
|
|
||||||
<span style={{ color: 'var(--muted)', marginLeft: 4 }}>
|
|
||||||
({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} €)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="quick" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||||
|
{line.name}
|
||||||
|
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="extras" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||||
|
{line.name}
|
||||||
|
{line.subName && <span> · {line.subName}</span>}
|
||||||
|
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<SectionIcon type="removed" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||||
|
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||||
|
<SectionIcon type="note" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -568,3 +701,144 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search Modal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
function SearchModal({ products, query, setQuery, onClose, onAdd }) {
|
||||||
|
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||||
|
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
|
||||||
|
|
||||||
|
const results = query.trim().length === 0
|
||||||
|
? []
|
||||||
|
: activeProducts.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(query.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
function openProduct(p) {
|
||||||
|
// Blur the input first so the keyboard dismisses, then open the drawer
|
||||||
|
document.activeElement?.blur()
|
||||||
|
setDrawerProduct(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The modal is position:fixed anchored to bottom:0.
|
||||||
|
// When the soft keyboard opens on mobile the browser shrinks the visual
|
||||||
|
// viewport and fixed elements reposition automatically — the panel sits
|
||||||
|
// right on top of the keyboard without any JS measurement needed.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Dim backdrop — tap to close */}
|
||||||
|
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }} />
|
||||||
|
|
||||||
|
{/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't
|
||||||
|
push the input off screen on short viewports */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||||
|
zIndex: 201,
|
||||||
|
background: 'var(--bg)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
}}>
|
||||||
|
{/* Results scroll area — flex:1 so it takes space above the input */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
{query.trim().length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
|
||||||
|
Πληκτρολογήστε για αναζήτηση…
|
||||||
|
</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
|
||||||
|
Δεν βρέθηκαν προϊόντα για «{query}»
|
||||||
|
</p>
|
||||||
|
) : results.map(p => {
|
||||||
|
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => openProduct(p)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
width: '100%', padding: '10px 16px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'var(--bg3)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{p.image_url
|
||||||
|
? <img src={`${API_URL}${p.image_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
|
||||||
|
{Number(p.base_price).toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||||
|
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input — pinned at the bottom of the panel, above the keyboard */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 12px 12px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||||
|
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
|
||||||
|
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="Αναζήτηση προϊόντος…"
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 44, background: 'var(--bg2)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 12,
|
||||||
|
padding: '0 12px', fontSize: 16, color: 'var(--text)',
|
||||||
|
fontFamily: 'inherit', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg3)', border: 'none', borderRadius: '50%',
|
||||||
|
width: 36, height: 36, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product drawer — closes search modal when item is added */}
|
||||||
|
{drawerProduct && (
|
||||||
|
<OrderDrawer
|
||||||
|
product={drawerProduct}
|
||||||
|
isOpen
|
||||||
|
onClose={() => setDrawerProduct(null)}
|
||||||
|
onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,14 +81,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [waiters, setWaiters] = useState([])
|
const [waiters, setWaiters] = useState([])
|
||||||
const [loadingWaiters, setLoadingWaiters] = useState(true)
|
const [loadingWaiters, setLoadingWaiters] = useState(true)
|
||||||
|
const [serverUnreachable, setServerUnreachable] = useState(false)
|
||||||
const [selectedWaiter, setSelectedWaiter] = useState(null)
|
const [selectedWaiter, setSelectedWaiter] = useState(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.get('/api/auth/waiters')
|
client.get('/api/auth/waiters')
|
||||||
.then(r => setWaiters(r.data))
|
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
|
||||||
.catch(() => setWaiters([]))
|
.catch(err => {
|
||||||
|
// No response = network error = server unreachable
|
||||||
|
if (!err.response) setServerUnreachable(true)
|
||||||
|
setWaiters([])
|
||||||
|
})
|
||||||
.finally(() => setLoadingWaiters(false))
|
.finally(() => setLoadingWaiters(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -130,6 +135,30 @@ export default function LoginPage() {
|
|||||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||||
{loadingWaiters ? (
|
{loadingWaiters ? (
|
||||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση…</p>
|
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση…</p>
|
||||||
|
) : serverUnreachable ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>🔌</div>
|
||||||
|
<p style={{ fontSize: 17, fontWeight: 700, color: '#ef4444', marginBottom: 8 }}>
|
||||||
|
Δεν βρέθηκε ο Server
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 24 }}>
|
||||||
|
Δεν είναι δυνατή η σύνδεση με τον Manager.<br />
|
||||||
|
Δεν μπορεί να ξεκινήσει βάρδια χωρίς σύνδεση.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn--secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setLoadingWaiters(true)
|
||||||
|
setServerUnreachable(false)
|
||||||
|
client.get('/api/auth/waiters')
|
||||||
|
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
|
||||||
|
.catch(err => { if (!err.response) setServerUnreachable(true) })
|
||||||
|
.finally(() => setLoadingWaiters(false))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⟳ Επανάληψη
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : waiters.length === 0 ? (
|
) : waiters.length === 0 ? (
|
||||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
|
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -81,6 +81,80 @@ function SplitModal({ item, onConfirm, onClose }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Item action modal (long-press) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
|
||||||
|
const { items, singleStacked, multiSelect } = target
|
||||||
|
const label = multiSelect
|
||||||
|
? `${items.length} αντικείμενα επιλεγμένα`
|
||||||
|
: items[0]?.product?.name || `#${items[0]?.product_id}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
|
||||||
|
<div className="modal-handle" />
|
||||||
|
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOrderAgain}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '16px 4px', background: 'none', border: 'none',
|
||||||
|
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
|
||||||
|
cursor: 'pointer', textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'rgba(245,158,11,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{singleStacked && !multiSelect && (
|
||||||
|
<button
|
||||||
|
onClick={onSplit}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '16px 4px', background: 'none', border: 'none',
|
||||||
|
cursor: 'pointer', textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'rgba(96,165,250,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
|
||||||
|
Άκυρο
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Actions top sheet ────────────────────────────────────────────────────────
|
// ─── Actions top sheet ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
|
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
|
||||||
@@ -429,6 +503,7 @@ export default function TableDetailPage() {
|
|||||||
const [allWaiters, setAllWaiters] = useState([])
|
const [allWaiters, setAllWaiters] = useState([])
|
||||||
const [actionDataLoading, setActionDataLoading] = useState(false)
|
const [actionDataLoading, setActionDataLoading] = useState(false)
|
||||||
const [splitItem, setSplitItem] = useState(null)
|
const [splitItem, setSplitItem] = useState(null)
|
||||||
|
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
|
||||||
|
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
|
|
||||||
@@ -800,7 +875,15 @@ export default function TableDetailPage() {
|
|||||||
selectable={canInteract && !paying}
|
selectable={canInteract && !paying}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleItem}
|
onToggle={toggleItem}
|
||||||
onLongPressItem={(item) => { setSplitItem(item) }}
|
onLongPressItem={(item) => {
|
||||||
|
// If multiple items are selected, order-again all selected items
|
||||||
|
if (selectedIds.length > 1) {
|
||||||
|
const items = activeItems.filter(i => selectedIds.includes(i.id))
|
||||||
|
setItemActionTarget({ items, singleStacked: false, multiSelect: true })
|
||||||
|
} else {
|
||||||
|
setItemActionTarget({ items: [item], singleStacked: item.quantity > 1, multiSelect: false })
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating controls row — only visible when items are selected */}
|
{/* Floating controls row — only visible when items are selected */}
|
||||||
@@ -937,6 +1020,32 @@ export default function TableDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Item action modal (long-press) */}
|
||||||
|
{itemActionTarget && (
|
||||||
|
<ItemActionModal
|
||||||
|
target={itemActionTarget}
|
||||||
|
onOrderAgain={() => {
|
||||||
|
const items = itemActionTarget.items
|
||||||
|
sessionStorage.setItem('orderAgainItems', JSON.stringify(
|
||||||
|
items.map(it => ({
|
||||||
|
product_id: it.product_id,
|
||||||
|
quantity: it.quantity,
|
||||||
|
selected_options: (() => { try { return JSON.parse(it.selected_options || '[]') } catch { return [] } })(),
|
||||||
|
removed_ingredients: (() => { try { return JSON.parse(it.removed_ingredients || '[]') } catch { return [] } })(),
|
||||||
|
notes: it.notes || '',
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
setItemActionTarget(null)
|
||||||
|
navigate(`/tables/${tableId}/add`)
|
||||||
|
}}
|
||||||
|
onSplit={() => {
|
||||||
|
setSplitItem(itemActionTarget.items[0])
|
||||||
|
setItemActionTarget(null)
|
||||||
|
}}
|
||||||
|
onClose={() => setItemActionTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Split stepper modal */}
|
{/* Split stepper modal */}
|
||||||
{splitItem && (
|
{splitItem && (
|
||||||
<SplitModal
|
<SplitModal
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import TableCard from '../components/TableCard'
|
import TableCard from '../components/TableCard'
|
||||||
import ConnectionBanner from '../components/ConnectionBanner'
|
import ConnectionBanner from '../components/ConnectionBanner'
|
||||||
|
import EmergencyBar from '../components/EmergencyBar'
|
||||||
import UserMenu from '../components/UserMenu'
|
import UserMenu from '../components/UserMenu'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import useTableColourStore from '../store/tableColourStore'
|
import useTableColourStore from '../store/tableColourStore'
|
||||||
|
import useConnectionStore from '../store/connectionStore'
|
||||||
|
import useTableViewStore from '../store/tableViewStore'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
|
import db from '../db/posdb'
|
||||||
|
import { queueOfflinePayment } from '../services/offlinePayments'
|
||||||
import { useNotifications } from '../context/NotificationContext'
|
import { useNotifications } from '../context/NotificationContext'
|
||||||
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
|
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
|
||||||
|
|
||||||
const FILTERS = ['all', 'mine', 'free']
|
|
||||||
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
|
|
||||||
|
|
||||||
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
|
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
|
||||||
|
|
||||||
// ─── Notification history drawer ─────────────────────────────────────────────
|
// ─── Icons ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NotificationDrawer({ messages, onClose, onAck }) {
|
function FilterIcon({ size = 20 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification drawer ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NotificationDrawer({ messages, onClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||||
@@ -37,9 +49,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
|
|||||||
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
|
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{msg.sender_name && (
|
{msg.sender_name && (
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
|
||||||
{msg.sender_name}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||||
{tableIds.length > 0 && (
|
{tableIds.length > 0 && (
|
||||||
@@ -59,7 +69,7 @@ function NotificationDrawer({ messages, onClose, onAck }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Table quick-view + actions popup (long-press) ────────────────────────────
|
// ─── Table quick-view modal (long press) ──────────────────────────────────────
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
||||||
@@ -77,25 +87,18 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
const due = Math.max(0, total - paid)
|
const due = Math.max(0, total - paid)
|
||||||
|
|
||||||
const statusLabel = {
|
const statusLabel = {
|
||||||
open: 'Ανοιχτό',
|
open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
|
||||||
partially_paid: 'Μερικώς πληρωμένο',
|
|
||||||
paid: 'Πληρωμένο',
|
|
||||||
}[order?.status] || 'Ελεύθερο'
|
}[order?.status] || 'Ελεύθερο'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
{/* Status overview card */}
|
|
||||||
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{
|
<div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||||
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
|
|
||||||
padding: '16px 20px', borderBottom: '1px solid var(--border)',
|
|
||||||
}}>
|
|
||||||
<div className="modal-handle" style={{ marginBottom: 12 }} />
|
<div className="modal-handle" style={{ marginBottom: 12 }} />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
|
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
|
||||||
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order ? (
|
{order ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||||
@@ -116,7 +119,6 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
) : (
|
) : (
|
||||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
|
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{flags.length > 0 && (
|
{flags.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
{flags.map(f => (
|
{flags.map(f => (
|
||||||
@@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button className="btn btn--primary" style={{ width: '100%', marginTop: 14 }} onClick={() => { onClose(); onNavigate() }}>
|
||||||
<button
|
|
||||||
className="btn btn--primary"
|
|
||||||
style={{ width: '100%', marginTop: 14 }}
|
|
||||||
onClick={() => { onClose(); onNavigate() }}
|
|
||||||
>
|
|
||||||
Άνοιγμα τραπεζιού
|
Άνοιγμα τραπεζιού
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg2)', borderRadius: '0 0 16px 16px', padding: '8px 20px 24px', borderTop: '2px solid var(--border)' }}>
|
||||||
{/* Quick actions card */}
|
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>ACTIONS</p>
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
|
|
||||||
padding: '8px 20px 24px',
|
|
||||||
borderTop: '2px solid var(--border)',
|
|
||||||
}}>
|
|
||||||
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
|
|
||||||
ACTIONS
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
{QUICK_ACTIONS.map((a, i) => {
|
{QUICK_ACTIONS.map((a, i) => {
|
||||||
const disabled = !order && a.key !== 'flags'
|
const disabled = !order && a.key !== 'flags'
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
||||||
key={a.key}
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
disabled={disabled}
|
padding: '12px 0', background: 'none', border: 'none',
|
||||||
onClick={() => { onClose(); onAction(a.key) }}
|
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
style={{
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
display: 'flex', alignItems: 'center', gap: 14,
|
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
||||||
padding: '12px 0', background: 'none', border: 'none',
|
}}>
|
||||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: a.iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', color: a.color }}>
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
|
|
||||||
background: a.iconBg,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: a.color,
|
|
||||||
}}>
|
|
||||||
<a.Icon width="18" height="18" />
|
<a.Icon width="18" height="18" />
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
|
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
|
||||||
@@ -187,27 +166,225 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Emergency payment modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmergencyPayModal({ table, order, onClose, onPay }) {
|
||||||
|
const [paying, setPaying] = useState(false)
|
||||||
|
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||||
|
const total = activeItems.reduce((s, i) => s + (i.unit_price || 0) * (i.quantity || 1), 0)
|
||||||
|
|
||||||
|
async function handlePay() {
|
||||||
|
setPaying(true)
|
||||||
|
await onPay(order.id, activeItems.map(i => i.id), 'cash')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||||
|
<div className="modal-handle" />
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🚨</div>
|
||||||
|
<p style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ</p>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>Τραπέζι: <strong>{table.label || `T${table.number}`}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg3)', borderRadius: 12, padding: '12px 16px', marginBottom: 20 }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>Ενεργά αντικείμενα:</p>
|
||||||
|
{activeItems.length === 0
|
||||||
|
? <p style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>Δεν υπάρχουν δεδομένα (offline snapshot)</p>
|
||||||
|
: activeItems.map(item => (
|
||||||
|
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, marginBottom: 4 }}>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{item.product?.name || `#${item.product_id}`} ×{item.quantity}</span>
|
||||||
|
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginTop: 10, paddingTop: 10, display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 16 }}>
|
||||||
|
<span>Σύνολο</span>
|
||||||
|
<span style={{ color: '#ef4444' }}>{total.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{total === 0
|
||||||
|
? <p style={{ fontSize: 13, color: '#ef4444', marginBottom: 16, lineHeight: 1.5, fontWeight: 600 }}>
|
||||||
|
Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.
|
||||||
|
</p>
|
||||||
|
: <p style={{ fontSize: 12, color: '#f59e0b', marginBottom: 16, lineHeight: 1.5 }}>
|
||||||
|
⚠️ Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
style={{ flex: 1, height: 44, borderRadius: 12, border: 'none', background: total === 0 ? '#64748b' : '#dc2626', color: '#fff', fontSize: 15, fontWeight: 700, cursor: (paying || total === 0) ? 'not-allowed' : 'pointer', opacity: (paying || total === 0) ? 0.5 : 1 }}
|
||||||
|
onClick={handlePay} disabled={paying || total === 0}
|
||||||
|
>
|
||||||
|
{paying ? '⟳ Καταχώρηση…' : '✓ Πληρωμή'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filters modal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FiltersModal({ groups, onClose }) {
|
||||||
|
const {
|
||||||
|
ownerFilter, statusFilter, zoneFilter,
|
||||||
|
setOwnerFilter, setStatusFilter, setZoneFilter,
|
||||||
|
clearFilters, setActiveZoneTab,
|
||||||
|
} = useTableViewStore()
|
||||||
|
|
||||||
|
function toggleZone(id) {
|
||||||
|
const next = zoneFilter.includes(id)
|
||||||
|
? zoneFilter.filter(z => z !== id)
|
||||||
|
: [...zoneFilter, id]
|
||||||
|
setZoneFilter(next)
|
||||||
|
// if we remove a zone that is the active tab, reset to 'all'
|
||||||
|
if (!next.length) setActiveZoneTab('all')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose} style={{ alignItems: 'flex-end' }}>
|
||||||
|
<div
|
||||||
|
className="modal-sheet"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }}
|
||||||
|
>
|
||||||
|
<div className="modal-handle" />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)' }}>Φίλτρα</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => { clearFilters(); onClose() }}
|
||||||
|
style={{ fontSize: 13, fontWeight: 600, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
Καθαρισμός
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner: ALL | MINE */}
|
||||||
|
<div>
|
||||||
|
<p style={sectionLabel}>Ανάθεση</p>
|
||||||
|
<div style={segmentedWrap}>
|
||||||
|
{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => (
|
||||||
|
<button key={key} onClick={() => setOwnerFilter(key)} style={segBtn(ownerFilter === key)}>{lbl}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status: ALL | FREE | OPEN | PAID */}
|
||||||
|
<div>
|
||||||
|
<p style={sectionLabel}>Κατάσταση</p>
|
||||||
|
<div style={{ ...segmentedWrap, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
|
||||||
|
{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => (
|
||||||
|
<button key={key} onClick={() => setStatusFilter(key)} style={{ ...segBtn(statusFilter === key), borderRadius: 10 }}>{lbl}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zones: multi-select, one segmented container per zone */}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p style={sectionLabel}>Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||||||
|
{groups.map(g => {
|
||||||
|
const active = zoneFilter.includes(g.id)
|
||||||
|
return (
|
||||||
|
<div key={g.id} style={segmentedWrap}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleZone(g.id)}
|
||||||
|
style={{
|
||||||
|
...segBtn(active),
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.color && (
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: active ? 'currentColor' : g.color,
|
||||||
|
flexShrink: 0, opacity: active ? 0.9 : 1,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Εντάξει</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLabel = { fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 8 }
|
||||||
|
const segmentedWrap = { display: 'flex', gap: 6, background: 'var(--bg3)', borderRadius: 12, padding: 4 }
|
||||||
|
function segBtn(active) {
|
||||||
|
return {
|
||||||
|
flex: 1, padding: '9px 8px', borderRadius: 9, border: 'none',
|
||||||
|
cursor: 'pointer', fontWeight: 600, fontSize: 14,
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||||
|
transition: 'background 0.12s',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TableListPage() {
|
export default function TableListPage() {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
|
const { status: connStatus } = useConnectionStore()
|
||||||
|
const isEmergency = connStatus === 'emergency'
|
||||||
|
|
||||||
const [tables, setTables] = useState([])
|
const [tables, setTables] = useState([])
|
||||||
const [groups, setGroups] = useState([])
|
const [groups, setGroups] = useState([])
|
||||||
const [orders, setOrders] = useState([])
|
const [orders, setOrders] = useState([])
|
||||||
const [flagDefs, setFlagDefs] = useState([])
|
const [flagDefs, setFlagDefs] = useState([])
|
||||||
const [flagAssignments, setFlagAssignments] = useState([])
|
const [flagAssignments, setFlagAssignments] = useState([])
|
||||||
const [filter, setFilter] = useState('all')
|
const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup
|
||||||
const [offline, setOffline] = useState(false)
|
const [offline, setOffline] = useState(false)
|
||||||
const [zoneOpen, setZoneOpen] = useState(false)
|
|
||||||
const [selectedZones, setSelectedZones] = useState(new Set())
|
|
||||||
const [showNotifs, setShowNotifs] = useState(false)
|
const [showNotifs, setShowNotifs] = useState(false)
|
||||||
const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const zoneRef = useRef(null)
|
const [quickModal, setQuickModal] = useState(null)
|
||||||
const navigate = useNavigate()
|
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
|
||||||
|
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
|
||||||
|
|
||||||
const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {}
|
// pull-to-refresh state
|
||||||
|
const [pulling, setPulling] = useState(false)
|
||||||
|
const [pullY, setPullY] = useState(0)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const pullStart = useRef(null)
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
const PULL_THRESHOLD = 72
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const filterBtnRef = useRef(null)
|
||||||
|
|
||||||
|
const { unreadCount, recentMessages, fetchRecent } = useNotifications() || {}
|
||||||
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
||||||
|
|
||||||
|
const {
|
||||||
|
density, ownerFilter, statusFilter, zoneFilter, activeZoneTab, setActiveZoneTab,
|
||||||
|
} = useTableViewStore()
|
||||||
|
|
||||||
|
// ── Load from IndexedDB when offline ──────────────────────────────────────
|
||||||
|
const loadFromDB = useCallback(async () => {
|
||||||
|
const [dbTables, dbOrders] = await Promise.all([db.tables.toArray(), db.orders.toArray()])
|
||||||
|
setTables(dbTables.filter(t => t.is_active !== false))
|
||||||
|
setOrders(dbOrders)
|
||||||
|
setOffline(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (isEmergency) loadFromDB() }, [isEmergency])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setOffline(true)
|
const handler = () => setOffline(true)
|
||||||
window.addEventListener('backend-offline', handler)
|
window.addEventListener('backend-offline', handler)
|
||||||
@@ -215,28 +392,37 @@ export default function TableListPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onClick(e) {
|
const handler = () => load()
|
||||||
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
|
window.addEventListener('sse-reconnected', handler)
|
||||||
}
|
return () => window.removeEventListener('sse-reconnected', handler)
|
||||||
document.addEventListener('mousedown', onClick)
|
|
||||||
return () => document.removeEventListener('mousedown', onClick)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes] = await Promise.all([
|
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes, waitersRes] = await Promise.all([
|
||||||
client.get('/api/tables/'),
|
client.get('/api/tables/'),
|
||||||
client.get('/api/orders/active'),
|
client.get('/api/orders/active'),
|
||||||
client.get('/api/tables/groups'),
|
client.get('/api/tables/groups'),
|
||||||
client.get('/api/flags/defs'),
|
client.get('/api/flags/defs'),
|
||||||
client.get('/api/flags/assignments'),
|
client.get('/api/flags/assignments'),
|
||||||
client.get('/api/settings/'),
|
client.get('/api/settings/'),
|
||||||
|
client.get('/api/waiters/on-shift'),
|
||||||
])
|
])
|
||||||
setTables(tablesRes.data)
|
setTables(tablesRes.data)
|
||||||
setOrders(ordersRes.data)
|
const fullOrders = await Promise.all(
|
||||||
|
ordersRes.data.map(o =>
|
||||||
|
client.get(`/api/orders/${o.id}`)
|
||||||
|
.then(r => ({ ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [] }))
|
||||||
|
.catch(() => o)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setOrders(fullOrders)
|
||||||
setGroups(groupsRes.data)
|
setGroups(groupsRes.data)
|
||||||
setFlagDefs(flagDefsRes.data)
|
setFlagDefs(flagDefsRes.data)
|
||||||
setFlagAssignments(flagAssignRes.data)
|
setFlagAssignments(flagAssignRes.data)
|
||||||
|
setWaiters(waitersRes.data)
|
||||||
const raw = settingsRes.data?.['ui.table_colours']?.value
|
const raw = settingsRes.data?.['ui.table_colours']?.value
|
||||||
if (raw) loadFromBackend(raw)
|
if (raw) loadFromBackend(raw)
|
||||||
setOffline(false)
|
setOffline(false)
|
||||||
@@ -245,6 +431,48 @@ export default function TableListPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
// ── SSE live updates ───────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmergency) return
|
||||||
|
function onSSE(e) {
|
||||||
|
const { type, data } = e.detail
|
||||||
|
if (type === 'order_updated' || type === 'order_paid') {
|
||||||
|
client.get(`/api/orders/${data.order_id}`)
|
||||||
|
.then(r => {
|
||||||
|
const full = { ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? [] }
|
||||||
|
setOrders(prev => {
|
||||||
|
const exists = prev.find(o => o.id === data.order_id)
|
||||||
|
return exists ? prev.map(o => o.id === data.order_id ? full : o) : [...prev, full]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setOrders(prev => {
|
||||||
|
const existing = prev.find(o => o.id === data.order_id)
|
||||||
|
if (existing) return prev.map(o => o.id === data.order_id ? { ...o, status: data.status, table_id: data.table_id } : o)
|
||||||
|
return [...prev, { id: data.order_id, table_id: data.table_id, status: data.status, waiter_ids: [] }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else if (type === 'order_closed') {
|
||||||
|
setOrders(prev => prev.filter(o => o.id !== data.order_id))
|
||||||
|
} else if (type === 'table_flags_changed') {
|
||||||
|
client.get('/api/flags/assignments').then(r => setFlagAssignments(r.data)).catch(() => {})
|
||||||
|
} else if (type === 'table_list_changed') {
|
||||||
|
client.get('/api/tables/').then(r => setTables(r.data)).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('sse-event', onSSE)
|
||||||
|
return () => window.removeEventListener('sse-event', onSSE)
|
||||||
|
}, [isEmergency])
|
||||||
|
|
||||||
|
// ── Emergency payment ──────────────────────────────────────────────────────
|
||||||
|
async function handleEmergencyPay(orderId, itemIds, paymentMethod) {
|
||||||
|
await queueOfflinePayment({ orderId, itemIds, paymentMethod })
|
||||||
|
setLocalPaidOrderIds(prev => new Set([...prev, orderId]))
|
||||||
|
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'paid' } : o))
|
||||||
|
await db.orders.where('id').equals(orderId).modify({ status: 'paid' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived maps ───────────────────────────────────────────────────────────
|
||||||
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
|
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
|
||||||
const tableFlagsMap = {}
|
const tableFlagsMap = {}
|
||||||
flagAssignments.forEach(a => {
|
flagAssignments.forEach(a => {
|
||||||
@@ -252,36 +480,88 @@ export default function TableListPage() {
|
|||||||
const def = flagDefMap[a.flag_id]
|
const def = flagDefMap[a.flag_id]
|
||||||
if (def) tableFlagsMap[a.table_id].push(def)
|
if (def) tableFlagsMap[a.table_id].push(def)
|
||||||
})
|
})
|
||||||
|
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w]))
|
||||||
|
|
||||||
function getOrder(tableId) {
|
function getOrder(tableId) { return orders.find(o => o.table_id === tableId) }
|
||||||
return orders.find(o => o.table_id === tableId)
|
function isMyOrder(order) { return !!(order && user && order.waiter_ids?.includes(user.id)) }
|
||||||
|
function getOrderWaiters(order) {
|
||||||
|
if (!order) return []
|
||||||
|
return (order.waiter_ids || []).map(id => waiterMap[id]).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMyOrder(order) {
|
// ── Filtering logic ────────────────────────────────────────────────────────
|
||||||
if (!order || !user) return false
|
// Zones visible in top bar = those allowed by zoneFilter (or all if empty)
|
||||||
return order.waiter_ids?.includes(user.id)
|
const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
|
||||||
}
|
|
||||||
|
|
||||||
function toggleZone(id) {
|
// visibleGroups = groups shown in the top bar
|
||||||
setSelectedZones(prev => {
|
const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id))
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id)
|
// Validate activeZoneTab against current allowedZoneIds
|
||||||
return next
|
// If the active tab is no longer visible, reset to 'all'
|
||||||
})
|
const effectiveZoneTab = (
|
||||||
}
|
activeZoneTab === 'all' ||
|
||||||
|
visibleGroups.some(g => g.id === activeZoneTab)
|
||||||
|
) ? activeZoneTab : 'all'
|
||||||
|
|
||||||
const filtered = tables.filter(t => {
|
const filtered = tables.filter(t => {
|
||||||
const order = getOrder(t.id)
|
const order = getOrder(t.id)
|
||||||
if (filter === 'free' && order) return false
|
|
||||||
if (filter === 'mine' && !isMyOrder(order)) return false
|
// Status filter
|
||||||
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
|
if (statusFilter === 'free' && order) return false
|
||||||
|
if (statusFilter === 'open' && (!order || order.status === 'paid' || order.status === 'partially_paid')) return false
|
||||||
|
if (statusFilter === 'paid' && order?.status !== 'paid' && order?.status !== 'partially_paid') return false
|
||||||
|
|
||||||
|
// Owner filter
|
||||||
|
if (ownerFilter === 'mine' && !isMyOrder(order)) return false
|
||||||
|
|
||||||
|
// Zone filter from modal (multi-select restricts which zones are allowed)
|
||||||
|
if (allowedZoneIds && !allowedZoneIds.has(t.group_id ?? 'none')) return false
|
||||||
|
|
||||||
|
// Active zone tab (secondary, single-select within allowed)
|
||||||
|
if (effectiveZoneTab !== 'all' && t.group_id !== effectiveZoneTab) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const zoneActive = selectedZones.size > 0
|
// ── Pull-to-refresh handlers ───────────────────────────────────────────────
|
||||||
|
function onPullTouchStart(e) {
|
||||||
|
if (scrollRef.current?.scrollTop > 0) return
|
||||||
|
pullStart.current = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
function onPullTouchMove(e) {
|
||||||
|
if (pullStart.current === null) return
|
||||||
|
const dy = e.touches[0].clientY - pullStart.current
|
||||||
|
if (dy > 0 && scrollRef.current?.scrollTop <= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setPulling(true)
|
||||||
|
setPullY(Math.min(dy, PULL_THRESHOLD * 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onPullTouchEnd() {
|
||||||
|
if (!pulling) return
|
||||||
|
if (pullY >= PULL_THRESHOLD) {
|
||||||
|
setRefreshing(true)
|
||||||
|
await load()
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
setPulling(false)
|
||||||
|
setPullY(0)
|
||||||
|
pullStart.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid columns per density ───────────────────────────────────────────────
|
||||||
|
const gridCols = {
|
||||||
|
'1x1': 'repeat(4, 1fr)',
|
||||||
|
'2x1': 'repeat(2, 1fr)',
|
||||||
|
'2x2': 'repeat(2, 1fr)',
|
||||||
|
'4x1': '1fr',
|
||||||
|
'4x2': '1fr',
|
||||||
|
'4x3': '1fr',
|
||||||
|
}[density] || 'repeat(2, 1fr)'
|
||||||
|
|
||||||
|
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||||
|
|
||||||
function handleQuickAction(tableId, actionKey) {
|
function handleQuickAction(tableId, actionKey) {
|
||||||
// Navigate to table then trigger action via URL param so TableDetailPage can handle it
|
|
||||||
navigate(`/tables/${tableId}?action=${actionKey}`)
|
navigate(`/tables/${tableId}?action=${actionKey}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,15 +579,14 @@ export default function TableListPage() {
|
|||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
{(unreadCount || 0) > 0 && (
|
{(unreadCount || 0) > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', top: 6, right: 6,
|
position: 'absolute', top: 6, right: 6,
|
||||||
background: '#ef4444', color: 'white',
|
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||||
fontSize: 10, fontWeight: 700,
|
|
||||||
borderRadius: '50%', width: 16, height: 16,
|
borderRadius: '50%', width: 16, height: 16,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
@@ -319,109 +598,135 @@ export default function TableListPage() {
|
|||||||
<UserMenu />
|
<UserMenu />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{offline && <ConnectionBanner />}
|
{isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
|
||||||
|
|
||||||
<div className="filter-tabs">
|
{/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
|
||||||
{FILTERS.map(f => (
|
<div style={{
|
||||||
<button key={f} className={`filter-tab ${filter === f ? 'filter-tab--active' : ''}`} onClick={() => setFilter(f)}>
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
{FILTER_LABELS[f]}
|
padding: '10px 12px',
|
||||||
</button>
|
background: 'var(--bg)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
overflowX: 'auto', scrollbarWidth: 'none',
|
||||||
|
}}>
|
||||||
|
{/* ALL tab */}
|
||||||
|
<ZoneTab
|
||||||
|
label="Όλα"
|
||||||
|
active={effectiveZoneTab === 'all'}
|
||||||
|
onClick={() => setActiveZoneTab('all')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Per-zone tabs */}
|
||||||
|
{visibleGroups.map(g => (
|
||||||
|
<ZoneTab
|
||||||
|
key={g.id}
|
||||||
|
label={g.name}
|
||||||
|
color={g.color}
|
||||||
|
active={effectiveZoneTab === g.id}
|
||||||
|
onClick={() => setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div ref={zoneRef} style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
|
|
||||||
onClick={() => setZoneOpen(o => !o)}
|
|
||||||
>
|
|
||||||
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
|
|
||||||
</button>
|
|
||||||
{zoneOpen && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: '110%', right: 0, zIndex: 100,
|
|
||||||
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
|
|
||||||
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedZones(new Set())}
|
|
||||||
style={{
|
|
||||||
display: 'block', width: '100%', textAlign: 'left',
|
|
||||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
|
||||||
color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
|
|
||||||
background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
|
|
||||||
border: 'none', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Όλες οι ζώνες
|
|
||||||
</button>
|
|
||||||
{groups.map(g => (
|
|
||||||
<button
|
|
||||||
key={g.id}
|
|
||||||
onClick={() => toggleZone(g.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
|
||||||
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
|
||||||
color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
|
|
||||||
background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
|
|
||||||
border: 'none', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
|
||||||
{g.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{tables.some(t => !t.group_id) && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleZone('none')}
|
|
||||||
style={{
|
|
||||||
display: 'block', width: '100%', textAlign: 'left',
|
|
||||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
|
||||||
color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
|
|
||||||
background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
|
|
||||||
border: 'none', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Χωρίς ζώνη
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
|
{/* ── Table grid ───────────────────────────────────────────────────────── */}
|
||||||
<div className="table-grid">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}
|
||||||
|
onTouchStart={onPullTouchStart}
|
||||||
|
onTouchMove={onPullTouchMove}
|
||||||
|
onTouchEnd={onPullTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Pull-to-refresh indicator */}
|
||||||
|
{(pulling || refreshing) && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: Math.min(pullY, PULL_THRESHOLD),
|
||||||
|
color: 'var(--muted)', fontSize: 13, fontWeight: 600,
|
||||||
|
overflow: 'hidden', transition: pulling ? 'none' : 'height 0.2s',
|
||||||
|
}}>
|
||||||
|
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: gridCols,
|
||||||
|
gap: density === '1x1' ? 8 : 10,
|
||||||
|
padding: '12px 12px 88px',
|
||||||
|
alignContent: 'start',
|
||||||
|
}}>
|
||||||
{filtered.map(t => {
|
{filtered.map(t => {
|
||||||
const order = getOrder(t.id)
|
const order = getOrder(t.id)
|
||||||
const tableFlags = tableFlagsMap[t.id] || []
|
const tableFlags = tableFlagsMap[t.id] || []
|
||||||
const grp = groups.find(g => g.id === t.group_id)
|
const grp = groups.find(g => g.id === t.group_id)
|
||||||
// Free tables go straight to the item picker; occupied tables go to detail
|
const alreadyPaidLocally = order && localPaidOrderIds.has(order.id)
|
||||||
const destination = order
|
const orderWaiters = getOrderWaiters(order)
|
||||||
? `/tables/${t.id}`
|
|
||||||
: `/tables/${t.id}/add?new=1`
|
function handleClick() {
|
||||||
|
if (isEmergency) {
|
||||||
|
if (order && !alreadyPaidLocally && order.status !== 'paid' && order.status !== 'closed') {
|
||||||
|
setEmergencyPayModal({ table: t, order })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const destination = order ? `/tables/${t.id}` : `/tables/${t.id}/add?new=1`
|
||||||
|
navigate(destination)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCard
|
<TableCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
table={t}
|
table={t}
|
||||||
order={order}
|
order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
|
||||||
isMine={isMyOrder(order)}
|
isMine={isMyOrder(order)}
|
||||||
flags={tableFlags}
|
flags={tableFlags}
|
||||||
groupName={grp?.name || ''}
|
groupName={grp?.name || ''}
|
||||||
onClick={() => navigate(destination)}
|
waiterObjects={orderWaiters}
|
||||||
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
|
density={density}
|
||||||
|
onClick={handleClick}
|
||||||
|
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="fab" onClick={load} title="Ανανέωση">↺</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter FAB ───────────────────────────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
ref={filterBtnRef}
|
||||||
|
onClick={() => setShowFilters(true)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', bottom: 24, right: 24,
|
||||||
|
width: 52, height: 52, borderRadius: '50%', border: 'none',
|
||||||
|
background: hasActiveFilters ? '#ea6c00' : '#f97316',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.2)',
|
||||||
|
zIndex: 40,
|
||||||
|
transition: 'background 0.12s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterIcon size={20} />
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 0, right: 0,
|
||||||
|
background: '#ef4444', color: '#fff',
|
||||||
|
fontSize: 9, fontWeight: 800,
|
||||||
|
borderRadius: '50%', width: 16, height: 16,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{(ownerFilter !== 'all' ? 1 : 0) + (statusFilter !== 'all' ? 1 : 0) + (zoneFilter.length > 0 ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── Modals ────────────────────────────────────────────────────────────── */}
|
||||||
{showNotifs && (
|
{showNotifs && (
|
||||||
<NotificationDrawer
|
<NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
|
||||||
messages={recentMessages || []}
|
)}
|
||||||
onClose={() => setShowNotifs(false)}
|
|
||||||
onAck={ackMessage}
|
{showFilters && (
|
||||||
/>
|
<FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{quickModal && (
|
{quickModal && (
|
||||||
@@ -434,6 +739,43 @@ export default function TableListPage() {
|
|||||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{emergencyPayModal && (
|
||||||
|
<EmergencyPayModal
|
||||||
|
table={emergencyPayModal.table}
|
||||||
|
order={emergencyPayModal.order}
|
||||||
|
onClose={() => setEmergencyPayModal(null)}
|
||||||
|
onPay={handleEmergencyPay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Zone tab pill ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ZoneTab({ label, color, active, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '7px 12px', borderRadius: 20, border: 'none',
|
||||||
|
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||||
|
fontWeight: 600, fontSize: 13,
|
||||||
|
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||||
|
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||||
|
transition: 'background 0.12s, color 0.12s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{color && (
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: color, flexShrink: 0,
|
||||||
|
opacity: active ? 1 : 0.7,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'
|
|||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['all','pos-waiter.bonamin.gr'],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -15,8 +18,8 @@ export default defineConfig({
|
|||||||
background_color: '#0f172a',
|
background_color: '#0f172a',
|
||||||
theme_color: '#0f172a',
|
theme_color: '#0f172a',
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
|||||||
Reference in New Issue
Block a user