Compare commits

..

7 Commits

64 changed files with 5924 additions and 858 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -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).

View File

@@ -2,7 +2,7 @@ services:
cloud_backend:
build: ./cloud_backend
ports:
- "8001:8001"
- "8011:8001"
restart: unless-stopped
env_file:
- ./cloud_backend/.env
@@ -12,7 +12,7 @@ services:
backend:
build: ./local_backend
ports:
- "8000:8000"
- "8010:8000"
restart: unless-stopped
env_file:
- ./local_backend/.env
@@ -31,7 +31,7 @@ services:
volumes:
- ./waiter_pwa:/app
ports:
- "5173:5173"
- "5183:5173"
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0"
env_file:
- ./waiter_pwa/.env
@@ -45,7 +45,7 @@ services:
volumes:
- ./manager_dashboard:/app
ports:
- "5174:5174"
- "5184:5174"
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
env_file:
- ./manager_dashboard/.env
@@ -59,7 +59,7 @@ services:
volumes:
- ./sysadmin_panel:/app
ports:
- "5175:5175"
- "5185:5175"
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
env_file:
- ./sysadmin_panel/.env

View File

@@ -26,6 +26,7 @@ from routers import shifts as shifts_router
from routers import settings as settings_router
from routers import flags as flags_router
from routers import messages as messages_router
from routers import sse as sse_router
def _run_migrations():
@@ -111,10 +112,13 @@ def _run_migrations():
name VARCHAR NOT NULL,
emoji VARCHAR,
color VARCHAR DEFAULT '#6b7280',
text_color VARCHAR DEFAULT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Migration: add text_color if upgrading from older schema
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id),
@@ -177,6 +181,25 @@ def _run_migrations():
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
# Auto-expand flag for sub-categories on the PWA accordion
"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:
try:
@@ -189,6 +212,9 @@ def _run_migrations():
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
init_loop(asyncio.get_running_loop())
Base.metadata.create_all(bind=engine)
_run_migrations()
sync_task = await start_cloud_sync()
@@ -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(flags_router.router, prefix="/api/flags", tags=["flags"])
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ class Printer(Base):
ip_address = Column(String, nullable=False)
port = Column(Integer, default=9100, 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")
print_logs = relationship("PrintLog", back_populates="printer")

View File

@@ -72,6 +72,7 @@ class ProductQuickOption(Base):
sort_order = Column(Integer, default=0, nullable=False)
is_favorite = Column(Boolean, default=False, 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")

View File

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

View File

@@ -1,305 +1,343 @@
"""
Printer font & symbol test script.
Usage (inside Docker): python print_test.py [IP] [PORT]
Defaults to 10.98.20.25:9100
Printer comprehensive test script — Jolimark TP850UE
Usage: python print_test.py [IP] [PORT]
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 time
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
from escpos.printer import Network
# ── Low-level helpers ──────────────────────────────────────────────────────────
def _gr(text: str) -> bytes:
return text.encode('cp737', errors='replace')
def _open():
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
p._raw(b'\x1b\x40') # ESC @ reset
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
p._raw(b'\x1b\x40') # ESC @ — full reset
p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
return p
def _t(p, text: str):
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):
p._raw(b'\x1b\x61\x00')
_left(p)
_t(p, char * width + "\n")
def _center(p):
p._raw(b'\x1b\x61\x01')
def _page_header(p, title: str):
_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 ──────────────────────────────────────────────────
# Bit 0 → underline (not tested, minor)
# Bit 1 → double-strike (bold)
# Bit 3 → double-height
# Bit 4 → double-width
# Bit 5 → delete-line
# 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)
# ── ESC ! mode table ───────────────────────────────────────────────────────────
#
# Each entry: (esc_bang_byte, esc_e_bold, label)
# esc_bang_byte sets the mode via ESC ! n
# esc_e_bold adds ESC E on top (independent bold layer)
# We test every useful combination so you can see the exact visual result.
MODES = [
(0x00, "Normal (0x00)"),
(0x08, "Double-height bit3 (0x08)"),
(0x10, "Double-height bit4 (0x10)"),
(0x18, "Double-height + Bold (0x18)"),
(0x20, "Double-width (0x20)"),
(0x30, "Double-width + Double-height (0x30)"),
(0x38, "Double-width + Double-height + Bold (0x38)"),
ESC_BANG_MODES = [
# (byte, extra_bold, label)
(0x00, False, "0x00 Normal"),
(0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
(0x08, False, "0x08 Bold only (bit3)"),
(0x10, False, "0x10 Double-height (bit4)"),
(0x10, True, "0x10 +ESC E Double-height + Bold"),
(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):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (EN) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
def _esc_bang_section(p, english: bool):
lang = "EN" if english else "GR"
sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
for code, label in MODES:
for (byte_val, extra_bold, label) in ESC_BANG_MODES:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "TEST PRINT Hello World Abc123\n")
# Print the label in small normal text first
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\x21\x00')
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
def section_greek(p):
_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:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
p._raw(b'\x1b\x21\x00')
# Apply mode
p._raw(bytes([0x1b, 0x21, byte_val]))
if extra_bold:
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold: Καλημερα Κοσμε\n")
_t(p, sample_normal + "\n")
_t(p, sample_lower + "\n")
# Reset
_reset(p)
_t(p, "\n")
_divider(p)
# ── Pages 14: ESC ! 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)
# Label in tiny normal text
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")
_divider(p)
# Also show GS ! combined with ESC ! bold
_t(p, "\n")
_divider(p, "=")
p._raw(b'\x1b\x21\x00')
_t(p, "GS! + ESC E bold combined:\n")
_divider(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'\x1b\x21\x00')
p._raw(b'\x1d\x21\x00')
_t(p, "\n")
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)
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")
# ── Underline ──
_t(p, "\nUNDERLINE\n")
_divider(p, "-")
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: Hello World 123\n")
p._raw(b'\x1b\x2d\x00')
_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 (0x800xFF) ───────────────────────────
# ── White-on-black invert ──
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
_divider(p, "-")
p._raw(b'\x1d\x42\x01')
_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')
_t(p, "Normal after invert\n")
_divider(p)
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, "=")
_left(p)
_t(p, "Hex offset rows x16 columns\n\n")
# ── 90-degree rotation ──
_t(p, "\n90-DEGREE ROTATION (ESC V)\n")
_divider(p, "-")
p._raw(b'\x1b\x56\x01')
_t(p, "ROTATED TEXT\n")
p._raw(b'\x1b\x56\x00')
_t(p, "Normal again\n")
_divider(p)
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"),
# ── 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 specials:
row_bytes = bytes([code, 0x20]) # char + space
p._raw(row_bytes)
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')
# ── Section 5 — Underline ──────────────────────────────────────────────────
p._raw(b'\n\n\n')
p.cut()
p.close()
def section_underline(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== UNDERLINE TEST ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# ESC - n : underline 0=off, 1=thin, 2=thick
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
p._raw(b'\x1b\x2d\x00') # off
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
def section_invert(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')
_t(p, " INVERTED TEXT SAMPLE \n")
p._raw(b'\x1d\x42\x00')
_t(p, "Normal text after invert\n")
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 7 — QR Code sample ────────────────────────────────────────────
def section_qr(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== QR CODE SAMPLE ===\n")
p._raw(b'\x1b\x21\x00')
_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)
p._raw(b'\n')
# ── Main ───────────────────────────────────────────────────────────────────
# ── 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 ----
p = _open()
section_english(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 1 sent (English fonts)")
page_esc_bang(font_b=False, english=True)
print("Page 1 done — ESC! modes, Font A, English")
# ---- PAGE 2: Greek fonts ----
p = _open()
section_greek(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 2 sent (Greek fonts)")
page_esc_bang(font_b=True, english=True)
print("Page 2 done — ESC! modes, Font B, English")
# ---- PAGE 3: Symbols & special chars ----
p = _open()
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_esc_bang(font_b=False, english=False)
print("Page 3 done — ESC! modes, Font A, Greek")
# ---- PAGE 4: Underline + Invert + QR ----
p = _open()
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)")
page_esc_bang(font_b=True, english=False)
print("Page 4 done — ESC! modes, Font B, Greek")
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__":
main()

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ def _replace_quick_options(db, product, quick_options):
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
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,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
))
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None

View File

@@ -16,6 +16,20 @@ VALID_SETTINGS = {
"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.",
"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 = {
@@ -24,6 +38,18 @@ DEFAULTS = {
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"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",
}

View File

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

View File

@@ -5,7 +5,7 @@ from typing import List
from database import get_db
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 models.user import User
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])
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")
@@ -52,8 +61,17 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
return {"success": success, "error": error}
@router.post("/printers/test-order")
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_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()
if not printer:
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
@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")
def lock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = True

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,19 @@ from pydantic import BaseModel
from typing import Optional
PROTOCOLS = ["escpos_tcp"] # extend later as needed
class PrinterBase(BaseModel):
name: str
ip_address: str
port: int = 9100
is_active: bool = True
protocol: str = "escpos_tcp"
class PrinterCreate(PrinterBase):
pass
class PrinterUpdate(BaseModel):
@@ -14,6 +22,7 @@ class PrinterUpdate(BaseModel):
ip_address: Optional[str] = None
port: Optional[int] = None
is_active: Optional[bool] = None
protocol: Optional[str] = None
class PrinterOut(PrinterBase):

View File

@@ -57,6 +57,7 @@ class ProductQuickOptionCreate(BaseModel):
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
is_compact: bool = False
class ProductQuickOptionOut(BaseModel):
@@ -68,6 +69,7 @@ class ProductQuickOptionOut(BaseModel):
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
is_compact: bool = False
model_config = {"from_attributes": True}

View File

@@ -20,6 +20,7 @@ from database import SessionLocal
from models.order import Order, OrderItem, PrintLog
from models.printer import Printer
from models.product import Product
from models.settings import PosSettings
logger = logging.getLogger(__name__)
@@ -46,19 +47,115 @@ def _raw_text(p: Network, text: str):
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(_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:
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
qty_str = str(qty)
gap = LINE_WIDTH - len(name) - len(qty_str)
if gap < 3:
return f"{name} {qty_str}"
dots = (". " * ((gap // 2) + 1))[:gap]
return f"{name}{dots}{qty_str}"
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
"""Build a dot-leader line ending with 'xN'.
line_width must reflect the effective width at the chosen font size
(double-width fonts halve the available char count to 24)."""
suffix = f"x{qty}"
available = line_width - len(name) - len(suffix)
if available < 2:
# Name alone is too long — put qty on same line with a single space
return f"{name} {suffix}"
dots = (". " * ((available // 2) + 1))[:available]
return f"{name}{dots}{suffix}"
def _apply_font(p: Network, size: int, bold: bool):
p._raw(bytes([0x1b, 0x21, size]))
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
def _reset_font(p: Network):
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
align: bytes = b'\x1b\x61\x00'):
"""Apply font, optionally capitalize, print text + newline, reset font."""
p._raw(align)
_apply_font(p, size, bold)
out = text.upper() if caps else text
_raw_text(p, out + "\n")
_reset_font(p)
def _greek_date(dt: datetime.datetime) -> str:
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
return dt.strftime("%H:%M %d-%m-%Y")
def check_printer(ip: str, port: int) -> bool:
@@ -73,7 +170,19 @@ def check_printer(ip: str, port: int) -> bool:
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]:
if is_spoof_mode():
logger.info("Spoof printing ON — dropping test print for %s", name)
return True, ""
try:
p = _get_printer(ip, port)
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)
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
"""Print a fake order using the current font/layout settings — for settings preview."""
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping test order print")
return True, ""
# ── Fake data structures (no DB writes) ──────────────────────────────────
class _Table:
label = "O2"
number = 2
class _User:
nickname = "bonamin"
username = "bonamin"
class _Order:
id = 99
table = _Table()
opener = _User()
table_id = 2
opened_by = 1
notes = "Χωρις καψαλισμα παρακαλω"
class _Item:
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
self.product_id = product_id
self.quantity = quantity
self.selected_options = selected_options
self.removed_ingredients = removed_ingredients
self.notes = notes
import json as _json
items = [
# Item 1: Freddo Espresso — quick options + preference + note
_Item(
product_id=1001,
quantity=2,
selected_options=_json.dumps([
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
]),
removed_ingredients=None,
notes="Πολυ κρυο παρακαλω",
),
# Item 2: Club Sandwich — extra with sub + removed ingredients
_Item(
product_id=1002,
quantity=1,
selected_options=_json.dumps([
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
]),
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
notes=None,
),
# Item 3: Margherita — quick + extra + removed
_Item(
product_id=1003,
quantity=3,
selected_options=_json.dumps([
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
]),
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
notes=None,
),
]
# Patch product lookup so _print_kitchen_ticket gets real names
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
# Monkey-patch db.query for Product only inside this call
_orig_query = db.query
class _FakeQuery:
def __init__(self, model):
self._model = model
self._filter_id = None
def filter(self, *args):
# extract id from the filter expression value
for arg in args:
try:
self._filter_id = arg.right.value
except Exception:
pass
return self
def first(self):
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
class _P:
name = _FAKE_NAMES[self._filter_id]
return _P()
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
class _PatchedDB:
def query(self, model):
from models.product import Product as _Product
if model is _Product:
return _FakeQuery(model)
return _orig_query(model)
# delegate everything else to real db
def __getattr__(self, name):
return getattr(db, name)
try:
p = _get_printer(ip, port)
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
p.close()
return True, ""
except Exception as e:
logger.error("Test order print failed for %s:%s%s", ip, port, e)
return False, str(e)
# ── Receipt formatting ───────────────────────────────────────────────────────
def _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):
# Header
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x38') # bold + double height + double width
_raw_text(p, f"Παραγγελια #{order.id}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
cfg = _load_print_settings(db)
mode = cfg.get("print.ticket_mode", "detailed")
div = cfg.get("print.divider_style", "dash")
compact = (mode == "compact")
# Meta
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
# 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')
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
_raw_text(p, f"Date: {now}\n")
_raw_text(p, f"Table: {order.table_id}\n")
_raw_text(p, f"Waiter: {order.opened_by}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_apply_font(p, sz_ord, b_ord)
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
_raw_text(p, (header.upper() if c_ord else header) + "\n")
_reset_font(p)
_divider(p, div)
# ── DETAILED header ──────────────────────────────────────────────────────
else:
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
align=b'\x1b\x61\x01')
_divider(p, div)
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_meta, b_meta)
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
_reset_font(p)
_divider(p, div)
# ── Items ────────────────────────────────────────────────────────────────
# Double-width fonts halve the effective character width
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
# Items
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}"
raw_name = product.name if product else f"Product #{item.product_id}"
item_name = raw_name.upper() if c_item else raw_name
p._raw(b'\x1b\x21\x10')
p._raw(b'\x1b\x45\x01') # bold on
_raw_text(p, _item_line(name, item.quantity) + "\n")
p._raw(b'\x1b\x45\x00') # bold off
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_item, b_item)
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
_reset_font(p)
opts = _parse_options(item)
# Quick options (* marker)
if opts["quick"]:
if compact:
parts = []
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
parts.append(f"{n} x{qty}" if qty > 1 else n)
_apply_font(p, sz_qk, b_qk)
_raw_text(p, "* " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, qty in opts["quick"]:
n = name.upper() if c_qk else name
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
_apply_font(p, sz_qk, b_qk)
_raw_text(p, line + "\n")
_reset_font(p)
# Preferences (> marker)
if opts["pref"]:
if compact:
parts = []
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
parts.append(f"{n} · {s}" if s else n)
_apply_font(p, sz_pr, b_pr)
_raw_text(p, "> " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub in opts["pref"]:
n = name.upper() if c_pr else name
s = (sub.upper() if c_pr else sub) if sub else None
line = f"> {n} · {s}" if s else f"> {n}"
_apply_font(p, sz_pr, b_pr)
_raw_text(p, line + "\n")
_reset_font(p)
# Extras (+ marker)
if opts["extra"]:
if compact:
parts = []
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
part = f"{n} · {s}" if s else n
if qty > 1:
part += f" · x{qty}"
parts.append(part)
_apply_font(p, sz_ex, b_ex)
_raw_text(p, "+ " + " | ".join(parts) + "\n")
_reset_font(p)
else:
for name, sub, qty in opts["extra"]:
n = name.upper() if c_ex else name
s = (sub.upper() if c_ex else sub) if sub else None
line = f"+ {n}"
if s:
line += f" · {s}"
if qty > 1:
line += f" · x{qty}"
_apply_font(p, sz_ex, b_ex)
_raw_text(p, line + "\n")
_reset_font(p)
# Legacy untagged options
for entry in opts["unknown"]:
_apply_font(p, sz_ex, b_ex)
_raw_text(p, f"+ {entry}\n")
_reset_font(p)
# Removed ingredients (- marker)
if item.removed_ingredients:
try:
removed_ids = json.loads(item.removed_ingredients)
if removed_ids:
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
except (json.JSONDecodeError, TypeError):
pass
if item.selected_options:
try:
option_ids = json.loads(item.selected_options)
if option_ids:
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
removed = json.loads(item.removed_ingredients)
if removed:
names = [n.upper() if c_ing else n for n in removed]
joined = " · ".join(names)
_apply_font(p, sz_ing, b_ing)
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
_reset_font(p)
except (json.JSONDecodeError, TypeError):
pass
# Per-item note
if item.notes:
_raw_text(p, f" (i) {item.notes}\n")
note_text = item.notes.upper() if c_note else item.notes
_apply_font(p, sz_note, b_note)
if compact:
_raw_text(p, f"! {note_text}\n")
else:
_raw_text(p, f"\n(!) {note_text}\n\n")
_reset_font(p)
p._raw(b'\x1b\x21\x00')
# 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:
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Σημειωσεις:\n")
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"{order.notes}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
note_text = order.notes.upper() if c_onote else order.notes
_apply_font(p, sz_onote, b_onote)
_raw_text(p, f"Σημ: {note_text}\n")
_reset_font(p)
if not compact:
_divider(p, div)
# Footer (detailed only)
if not compact:
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "Τελος Παραγγελιας\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
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):
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping waiter report print")
return
try:
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):
"""Print a per-printer totals report. mode='simple'|'extensive'."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping printer report print")
return
try:
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):
"""Print a manager-triggered order receipt."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping order receipt print")
return
try:
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):
"""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:
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)
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]:
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 = []
order = db.query(Order).filter(Order.id == order_id).first()

View File

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

View File

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

View File

@@ -766,6 +766,7 @@ function buildFormFromProduct(product) {
sort_order: q.sort_order ?? 0,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
is_compact: q.is_compact ?? false,
})) ?? [],
options: product.options?.map(o => ({
name: o.name,
@@ -906,7 +907,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
}
// ── 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 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) })) }
@@ -1083,6 +1084,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
sort_order: i,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
is_compact: q.is_compact ?? false,
})),
options: form.options.map(o => ({
name: o.name,
@@ -1346,6 +1348,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
className="accent-primary-700 w-4 h-4" />
Πολλαπλά
</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>
</div>
))}

View File

@@ -1,10 +1,14 @@
import { useState } from 'react'
import AppInfoTab from './tabs/AppInfoTab'
import ColoursTab from './tabs/ColoursTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import PrintFontsTab from './tabs/PrintFontsTab'
const TABS = [
{ key: 'app-info', label: 'App Info' },
{ key: 'colours', label: 'UI Personalization' },
{ key: 'print-fonts', label: 'Εκτύπωση' },
{ key: 'development', label: 'Development' },
]
export default function SettingsPage() {
@@ -46,6 +50,8 @@ export default function SettingsPage() {
{/* Tab content */}
{activeTab === 'app-info' && <AppInfoTab />}
{activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'development' && <DevelopmentTab />}
</div>
)
}

View File

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

View 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>
)
}

View 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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,7 +122,7 @@ function Row({ selected, onClick, children, right, left, style = {} }) {
// ── Shared: single quick option row ──────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) {
function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
const qty = quickState[opt.id] || 0
const selected = qty > 0
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
@@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
</div>
) : null}
>
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</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: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>}
</Row>
)
}
@@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{favorites.map((fav, fi) => {
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') {
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>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{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>
)
@@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) {
{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 style={{ flex: 1 }}>
{l.group === 'prefs' ? (
<>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</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>
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</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>}
</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 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 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 })
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
// Skip if this is entirely the default selection
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
})
@@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
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
})
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 }
@@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id]
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
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = sharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
}
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
// Don't emit entries that are entirely at their defaults — nothing changed
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
})
@@ -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 entries = []
for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
}
return entries
})
@@ -691,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 }))
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
})
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)

View File

@@ -1,26 +1,112 @@
import { useRef } from 'react'
import { useRef, useState } from 'react'
function fmtPrice(v) {
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 }) {
const isPaid = item.status === 'paid'
const isCancelled = item.status === 'cancelled'
const isStacked = item.quantity > 1
let opts = []
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {}
let removed = []
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
const sections = buildSections(item)
const hasDetails = sections.length > 0
const [expanded, setExpanded] = useState(false)
// Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll)
// Long-press detection
const pressTimer = useRef(null)
const didLongPress = useRef(false)
const touchStartPos = useRef({ x: 0, y: 0 })
function handleTouchStart(e) {
if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return
if (!selectable || isPaid || isCancelled || !onLongPress) return
didLongPress.current = false
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer.current = setTimeout(() => {
@@ -35,11 +121,9 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
}
function handleTouchEnd() {
clearTimeout(pressTimer.current)
}
function handleTouchEnd() { clearTimeout(pressTimer.current) }
function handleClick() {
function handleBodyClick() {
if (didLongPress.current) { didLongPress.current = false; return }
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
}
@@ -47,31 +131,115 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
return (
<div
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' }}
>
{/* Main row — click to select */}
<div
onClick={handleBodyClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
}}
>
<div className="order-item__row">
{/* Selection checkbox */}
{selectable && !isPaid && !isCancelled && (
<span style={{ marginRight: 8, color: selected ? '#f59e0b' : '#475569' }}>
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
{selected ? '☑' : '☐'}
</span>
)}
{/* Name + badges */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
<span className="order-item__qty">×{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>}
{!isPaid && !isCancelled && !item.printed && (
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα"></span>
)}
</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>)}
{item.notes && <div className="order-item__modifier">📝 {item.notes}</div>}
</div>
{/* Qty + price */}
<span className="order-item__qty">×{item.quantity}</span>
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
{hasDetails && (
<button
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
style={{
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
{/* Expanded details */}
{expanded && hasDetails && (
<div style={{ paddingBottom: 8 }}>
{sections.map((sec, si) => (
<div key={si}>
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{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>
)
}

View File

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

View File

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

View File

@@ -168,6 +168,12 @@ export default function UserMenu() {
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
</button>
{/* ── Settings ──────────────────────────────────────── */}
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
<span className="user-menu-item__icon"></span>
<span>Ρυθμίσεις</span>
</button>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ────────────────────────────────────────────────────────
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
@@ -429,6 +503,7 @@ export default function TableDetailPage() {
const [allWaiters, setAllWaiters] = useState([])
const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null)
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
const scrollRef = useRef(null)
@@ -800,7 +875,15 @@ export default function TableDetailPage() {
selectable={canInteract && !paying}
selectedIds={selectedIds}
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 */}
@@ -937,6 +1020,32 @@ export default function TableDetailPage() {
</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 */}
{splitItem && (
<SplitModal

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
server: {
allowedHosts: ['all','pos-waiter.bonamin.gr'],
},
plugins: [
react(),
VitePWA({
@@ -15,8 +18,8 @@ export default defineConfig({
background_color: '#0f172a',
theme_color: '#0f172a',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', 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', purpose: 'any maskable' },
],
},
workbox: {