diff --git a/CLAUDE_DESIGN/.design-canvas.state.json b/CLAUDE_DESIGN/.design-canvas.state.json index 6b13e20..2a63473 100644 --- a/CLAUDE_DESIGN/.design-canvas.state.json +++ b/CLAUDE_DESIGN/.design-canvas.state.json @@ -1 +1 @@ -{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}} \ No newline at end of file +{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}},"desktop":{"labels":{"desktop-main":"1440×900 — full operational view, mid-shift"}}}} \ No newline at end of file diff --git a/CLAUDE_DESIGN/Table Grid Densities.html b/CLAUDE_DESIGN/Table Grid Densities.html new file mode 100644 index 0000000..d29c56b --- /dev/null +++ b/CLAUDE_DESIGN/Table Grid Densities.html @@ -0,0 +1,39 @@ + + + + + +Table Grid Densities — SimplePOS + + + + + + +
+ + + + + + + + + + + + diff --git a/CLAUDE_DESIGN/design-canvas.jsx b/CLAUDE_DESIGN/design-canvas.jsx index 9f3fc61..043475f 100644 --- a/CLAUDE_DESIGN/design-canvas.jsx +++ b/CLAUDE_DESIGN/design-canvas.jsx @@ -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: // @@ -39,17 +39,58 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { '.dc-card{transition:box-shadow .15s,transform .15s}', '.dc-card *{scrollbar-width:none}', '.dc-card *::-webkit-scrollbar{display:none}', - '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}', - '.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', '.dc-grip:hover{background:rgba(0,0,0,.08)}', '.dc-grip:active{cursor:grabbing}', - '.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', - '.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;', - ' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', - ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}', + '.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}', - '[data-dc-slot]:hover .dc-expand{opacity:1}', + '.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}', + '.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;', + ' font-size:12px;font-weight:500}', + '.dc-delete.dc-confirm:hover{background:#b5563a}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', ].join('\n'); document.head.appendChild(s); } @@ -58,8 +99,9 @@ const DCCtx = React.createContext(null); // ───────────────────────────────────────────────────────────── // DesignCanvas — stateful wrapper around the pan/zoom viewport. -// Owns runtime state (per-section order, renamed titles/labels, focused -// artboard). Order/titles/labels persist to a .design-canvas.state.json +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json // sidecar next to the HTML. Reads go via plain fetch() so the saved // arrangement is visible anywhere the HTML + sidecar are served together // (omelette preview, direct link, downloaded zip). Writes go through the @@ -115,11 +157,19 @@ function DesignCanvas({ children, minScale, maxScale, style }) { if (!sid) return; sectionOrder.push(sid); const persisted = state.sections[sid] || {}; - const srcIds = []; + const abs = []; React.Children.forEach(sec.props.children, (ab) => { if (!ab || ab.type !== DCArtboard) return; const aid = ab.props.id ?? ab.props.label; - if (!aid) return; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; srcIds.push(aid); }); @@ -183,11 +233,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { const vpRef = React.useRef(null); const worldRef = React.useRef(null); const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + const lastPostedScale = React.useRef(); const apply = React.useCallback(() => { const { x, y, scale } = tf.current; const el = worldRef.current; - if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; }, []); React.useEffect(() => { @@ -272,6 +359,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { vp.style.cursor = ''; }; + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + vp.addEventListener('wheel', onWheel, { passive: false }); vp.addEventListener('gesturestart', onGestureStart, { passive: false }); vp.addEventListener('gesturechange', onGestureChange, { passive: false }); @@ -281,6 +398,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { vp.addEventListener('pointerup', onPointerUp); vp.addEventListener('pointercancel', onPointerUp); return () => { + window.removeEventListener('message', onHostMsg); vp.removeEventListener('wheel', onWheel); vp.removeEventListener('gesturestart', onGestureStart); vp.removeEventListener('gesturechange', onGestureChange); @@ -336,8 +454,13 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) { const all = React.Children.toArray(children); const artboards = all.filter((c) => c && c.type === DCArtboard); const rest = all.filter((c) => !(c && c.type === DCArtboard)); - const srcOrder = artboards.map((a) => a.props.id ?? a.props.label); const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); const order = React.useMemo(() => { const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); @@ -346,13 +469,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) { const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. return ( -
-
- ctx && sid && ctx.patchSection(sid, { title: v })} - style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> - {subtitle &&
{subtitle}
} +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
{order.map((k) => ( @@ -360,6 +492,10 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) { label={(sec.labels || {})[k] ?? byId[k].props.label} onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> ))}
@@ -371,10 +507,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) { // DCArtboard — marker; rendered by DCArtboardFrame via DCSection. function DCArtboard() { return null; } -function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) { +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; const id = rawId ?? rawLabel; const ref = React.useRef(null); + const delRef = React.useRef(null); + const [confirming, setConfirming] = React.useState(false); + + // Two-click delete: first click arms the button (turns into an inline + // "Delete?" pill), second click commits. Any pointerdown outside the + // button disarms. + React.useEffect(() => { + if (!confirming) return; + const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [confirming]); // Live drag-reorder: dragged card sticks to cursor; siblings slide into // their would-be slots in real time via transforms. DOM order only @@ -440,18 +588,32 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde return (
-
-
- +
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
-
- e.stopPropagation()} - style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+ +
-
{children ||
{id}
} @@ -489,9 +651,14 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; const goSection = (d) => { - const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length]; - const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; - if (first) ctx.setFocus(`${ns}/${first}`); + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } }; React.useEffect(() => { @@ -548,7 +715,7 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { {ddOpen && (
- {sectionOrder.map((sid) => ( + {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + ))} +
+ ); +} + +function Header({ density }) { + return ( +
+
Tables
+ +
+ dimitris + +
+
+ ); +} + +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 ( +
+
+ +
+
+ {TABLES.map(t => ( + + ))} +
+
+
+ ); +} + +function App() { + const order = ['1x1', '2x1', '2x2', '4x1', '4x2']; + return ( + + + {order.map(k => { + const d = DENSITIES[k]; + return ( + +
+ + + +
+
+ ); + })} +
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/CLAUDE_DESIGN/tables-data.jsx b/CLAUDE_DESIGN/tables-data.jsx new file mode 100644 index 0000000..b42bede --- /dev/null +++ b/CLAUDE_DESIGN/tables-data.jsx @@ -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; diff --git a/CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png b/CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png new file mode 100644 index 0000000..eaa2b50 Binary files /dev/null and b/CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png differ diff --git a/CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png b/CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png new file mode 100644 index 0000000..c7fd830 Binary files /dev/null and b/CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png differ diff --git a/local_backend/main.py b/local_backend/main.py index 05e5692..1094f53 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -26,6 +26,7 @@ from routers import shifts as shifts_router from routers import settings as settings_router from routers import flags as flags_router from routers import messages as messages_router +from routers import sse as sse_router def _run_migrations(): @@ -111,10 +112,13 @@ def _run_migrations(): name VARCHAR NOT NULL, emoji VARCHAR, color VARCHAR DEFAULT '#6b7280', + text_color VARCHAR DEFAULT NULL, sort_order INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )""", + # Migration: add text_color if upgrading from older schema + "ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL", """CREATE TABLE IF NOT EXISTS table_flag_assignments ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_id INTEGER NOT NULL REFERENCES tables(id), @@ -181,6 +185,21 @@ def _run_migrations(): "ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'", # Compact (half-width) display flag for quick options "ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0", + # Print layout + per-type font settings + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)", + # Offline/emergency payment tracking + "ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR", + "ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR", + "ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0", ] for sql in migrations: try: @@ -193,6 +212,9 @@ def _run_migrations(): @asynccontextmanager async def lifespan(app: FastAPI): + import asyncio + from services.sse_bus import init_loop + init_loop(asyncio.get_running_loop()) Base.metadata.create_all(bind=engine) _run_migrations() sync_task = await start_cloud_sync() @@ -232,3 +254,4 @@ app.include_router(shifts_router.router, prefix="/api/shifts", tag app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"]) app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"]) +app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"]) diff --git a/local_backend/models/flag.py b/local_backend/models/flag.py index 6dc893a..b6914b7 100644 --- a/local_backend/models/flag.py +++ b/local_backend/models/flag.py @@ -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) diff --git a/local_backend/models/order.py b/local_backend/models/order.py index 628466d..d214f57 100644 --- a/local_backend/models/order.py +++ b/local_backend/models/order.py @@ -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") diff --git a/local_backend/print_size_test.py b/local_backend/print_size_test.py new file mode 100644 index 0000000..1c4df82 --- /dev/null +++ b/local_backend/print_size_test.py @@ -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.") diff --git a/local_backend/routers/flags.py b/local_backend/routers/flags.py index 935bdf8..4bc10c5 100644 --- a/local_backend/routers/flags.py +++ b/local_backend/routers/flags.py @@ -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": []}) diff --git a/local_backend/routers/messages.py b/local_backend/routers/messages.py index 47406e5..ece8894 100644 --- a/local_backend/routers/messages.py +++ b/local_backend/routers/messages.py @@ -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]) diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index fb30983..d157f3f 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -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 diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py index 933738a..22cc038 100644 --- a/local_backend/routers/settings.py +++ b/local_backend/routers/settings.py @@ -17,13 +17,19 @@ VALID_SETTINGS = { "system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.", "ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.", "dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.", - # Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1 - "print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')", - "print.font_options": "Font for option/modifier lines: SIZE:BOLD", - "print.font_table": "Font for table/waiter header lines: SIZE:BOLD", - "print.font_order_number": "Font for order number header: SIZE:BOLD", - "print.font_header": "Font for top header block: SIZE:BOLD", + # Print layout + "print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'", "print.divider_style": "Divider character used between sections: dash, equals, star, or empty", + # Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1 + "print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS", + "print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS", + "print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS", + "print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS", + "print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS", + "print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS", + "print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS", + "print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS", + "print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS", } DEFAULTS = { @@ -33,12 +39,17 @@ DEFAULTS = { "system.timezone": "Europe/Athens", "ui.table_colours": "", "dev.spoof_printing": "false", - "print.font_item_name": "16:0", # double-height, no bold - "print.font_options": "0:0", # normal - "print.font_table": "16:0", # double-height - "print.font_order_number": "48:1", # double-height + double-width + bold - "print.font_header": "48:1", # double-height + double-width + bold + "print.ticket_mode": "detailed", "print.divider_style": "dash", + "print.font_order_number": "48:1:0", + "print.font_meta": "0:0:0", + "print.font_item_name": "16:1:0", + "print.font_quick": "0:0:0", + "print.font_pref": "0:0:0", + "print.font_extra": "0:0:0", + "print.font_ingredient": "0:0:0", + "print.font_item_note": "0:0:0", + "print.font_order_note": "0:1:0", } diff --git a/local_backend/routers/sse.py b/local_backend/routers/sse.py new file mode 100644 index 0000000..6ad6910 --- /dev/null +++ b/local_backend/routers/sse.py @@ -0,0 +1,60 @@ +""" +SSE stream endpoint — one long-lived GET per connected phone. + +Authentication: token passed as query param ?token= +(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", + }, + ) diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index 939fad4..fbc9eaf 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -61,6 +61,15 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De return {"success": success, "error": error} +@router.post("/printers/test-order") +def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db) + return {"success": success, "error": error} + + @router.put("/printers/{printer_id}", response_model=PrinterOut) def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): printer = db.query(Printer).filter(Printer.id == printer_id).first() diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py index dd021b0..c02b2ae 100644 --- a/local_backend/routers/tables.py +++ b/local_backend/routers/tables.py @@ -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 diff --git a/local_backend/schemas/flag.py b/local_backend/schemas/flag.py index 505c87c..19bea65 100644 --- a/local_backend/schemas/flag.py +++ b/local_backend/schemas/flag.py @@ -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 diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py index 5da7633..3fef693 100644 --- a/local_backend/schemas/order.py +++ b/local_backend/schemas/order.py @@ -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} diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index c146c76..7a730de 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -54,13 +54,32 @@ _DIVIDER_CHARS = { "empty": "", } -_PRINT_FONT_DEFAULTS = { - "print.font_item_name": "16:0", - "print.font_options": "0:0", - "print.font_table": "16:0", - "print.font_order_number": "48:1", - "print.font_header": "48:1", +_PRINT_SETTING_KEYS = [ + "print.ticket_mode", + "print.divider_style", + "print.font_order_number", + "print.font_meta", + "print.font_item_name", + "print.font_quick", + "print.font_pref", + "print.font_extra", + "print.font_ingredient", + "print.font_item_note", + "print.font_order_note", +] + +_PRINT_SETTING_DEFAULTS = { + "print.ticket_mode": "detailed", "print.divider_style": "dash", + "print.font_order_number": "48:1:0", + "print.font_meta": "0:0:0", + "print.font_item_name": "16:1:0", + "print.font_quick": "0:0:0", + "print.font_pref": "0:0:0", + "print.font_extra": "0:0:0", + "print.font_ingredient": "0:0:0", + "print.font_item_note": "0:0:0", + "print.font_order_note": "0:1:0", } # SIZE byte values (ESC ! base, no bold bit): @@ -68,27 +87,28 @@ _PRINT_FONT_DEFAULTS = { # 16 = double-height (bit4) # 32 = double-width (bit5) # 48 = double-height + double-width (bits 4+5) -# Bold is applied separately via ESC E. +# Bold applied via ESC E, caps applied in software before encoding. -def _decode_font(value: str) -> tuple[int, bool]: - """Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag).""" +def _decode_font(value: str) -> tuple[int, bool, bool]: + """Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag).""" try: parts = str(value).split(":") size = int(parts[0]) bold = len(parts) > 1 and parts[1] == "1" - return size, bold + caps = len(parts) > 2 and parts[2] == "1" + return size, bold, caps except (ValueError, AttributeError): - return 0, False + return 0, False, False -def _load_print_fonts(db: Session) -> dict: +def _load_print_settings(db: Session) -> dict: rows = db.query(PosSettings).filter( - PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys()) + PosSettings.key.in_(_PRINT_SETTING_KEYS) ).all() - fonts = dict(_PRINT_FONT_DEFAULTS) + settings = dict(_PRINT_SETTING_DEFAULTS) for row in rows: - fonts[row.key] = row.value - return fonts + settings[row.key] = row.value + return settings def _divider(p: Network, style: str = "dash"): @@ -100,14 +120,42 @@ def _divider(p: Network, style: str = "dash"): p._raw(b'\n') -def _item_line(name: str, qty: int) -> str: - """Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars.""" - qty_str = str(qty) - gap = LINE_WIDTH - len(name) - len(qty_str) - if gap < 3: - return f"{name} {qty_str}" - dots = (". " * ((gap // 2) + 1))[:gap] - return f"{name}{dots}{qty_str}" +def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str: + """Build a dot-leader line ending with 'xN'. + line_width must reflect the effective width at the chosen font size + (double-width fonts halve the available char count to 24).""" + suffix = f"x{qty}" + available = line_width - len(name) - len(suffix) + if available < 2: + # Name alone is too long — put qty on same line with a single space + return f"{name} {suffix}" + dots = (". " * ((available // 2) + 1))[:available] + return f"{name}{dots}{suffix}" + + +def _apply_font(p: Network, size: int, bold: bool): + p._raw(bytes([0x1b, 0x21, size])) + p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00') + + +def _reset_font(p: Network): + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1b\x45\x00') + + +def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool, + align: bytes = b'\x1b\x61\x00'): + """Apply font, optionally capitalize, print text + newline, reset font.""" + p._raw(align) + _apply_font(p, size, bold) + out = text.upper() if caps else text + _raw_text(p, out + "\n") + _reset_font(p) + + +def _greek_date(dt: datetime.datetime) -> str: + """Return date/time string in Greek format: HH:MM DD-MM-YYYY""" + return dt.strftime("%H:%M %d-%m-%Y") def check_printer(ip: str, port: int) -> bool: @@ -152,88 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: return False, str(e) +def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]: + """Print a fake order using the current font/layout settings — for settings preview.""" + if _is_spoof_mode(db): + logger.info("Spoof printing ON — dropping test order print") + return True, "" + + # ── Fake data structures (no DB writes) ────────────────────────────────── + class _Table: + label = "O2" + number = 2 + + class _User: + nickname = "bonamin" + username = "bonamin" + + class _Order: + id = 99 + table = _Table() + opener = _User() + table_id = 2 + opened_by = 1 + notes = "Χωρις καψαλισμα παρακαλω" + + class _Item: + def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes): + self.product_id = product_id + self.quantity = quantity + self.selected_options = selected_options + self.removed_ingredients = removed_ingredients + self.notes = notes + + import json as _json + + items = [ + # Item 1: Freddo Espresso — quick options + preference + note + _Item( + product_id=1001, + quantity=2, + selected_options=_json.dumps([ + {"name": "Διπλος", "price_delta": 0.5, "type": "quick"}, + {"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"}, + {"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"}, + {"name": "Γαλα", "price_delta": 0.0, "type": "pref"}, + {"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"}, + ]), + removed_ingredients=None, + notes="Πολυ κρυο παρακαλω", + ), + # Item 2: Club Sandwich — extra with sub + removed ingredients + _Item( + product_id=1002, + quantity=1, + selected_options=_json.dumps([ + {"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"}, + {"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"}, + {"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"}, + {"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"}, + {"name": "Ψωμι", "price_delta": 0.0, "type": "pref"}, + {"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"}, + ]), + removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]), + notes=None, + ), + # Item 3: Margherita — quick + extra + removed + _Item( + product_id=1003, + quantity=3, + selected_options=_json.dumps([ + {"name": "Well Done", "price_delta": 0.0, "type": "quick"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + ]), + removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]), + notes=None, + ), + ] + + # Patch product lookup so _print_kitchen_ticket gets real names + _FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"} + + # Monkey-patch db.query for Product only inside this call + _orig_query = db.query + + class _FakeQuery: + def __init__(self, model): + self._model = model + self._filter_id = None + def filter(self, *args): + # extract id from the filter expression value + for arg in args: + try: + self._filter_id = arg.right.value + except Exception: + pass + return self + def first(self): + if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES: + class _P: + name = _FAKE_NAMES[self._filter_id] + return _P() + return _orig_query(self._model).filter(self._model.id == self._filter_id).first() + + class _PatchedDB: + def query(self, model): + from models.product import Product as _Product + if model is _Product: + return _FakeQuery(model) + return _orig_query(model) + # delegate everything else to real db + def __getattr__(self, name): + return getattr(db, name) + + try: + p = _get_printer(ip, port) + _print_kitchen_ticket(p, _Order(), items, _PatchedDB()) + p.close() + return True, "" + except Exception as e: + logger.error("Test order print failed for %s:%s — %s", ip, port, e) + return False, str(e) + + # ── Receipt formatting ─────────────────────────────────────────────────────── -def _font(p: Network, byte_val: int, bold: bool = False): - p._raw(bytes([0x1b, 0x21, byte_val])) - p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00') +def _parse_options(item: OrderItem) -> dict: + """ + Parse selected_options JSON into grouped dict: + { 'quick': [(name, qty)], 'pref': [(name, sub|None)], + 'extra': [(name, sub|None, qty)], 'unknown': [name] } + Falls back gracefully when type tags are absent (old data). + """ + result = {"quick": [], "pref": [], "extra": [], "unknown": []} + if not item.selected_options: + return result + + try: + raw = json.loads(item.selected_options) + except (json.JSONDecodeError, TypeError): + return result + + if not isinstance(raw, list): + return result + + i = 0 + while i < len(raw): + entry = raw[i] + if not isinstance(entry, dict): + i += 1 + continue + name = entry.get("name") or "" + etype = entry.get("type") + + # Peek at next entry to collect sub-choice + sub = None + if i + 1 < len(raw): + nxt = raw[i + 1] + if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"): + sub = nxt.get("name") or "" + i += 1 # consume sub + + if etype == "quick": + # Collapse repeated quick entries into a single (name, qty) tuple + existing = next((q for q in result["quick"] if q[0] == name), None) + if existing: + result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1) + else: + result["quick"].append((name, 1)) + elif etype == "pref": + result["pref"].append((name, sub)) + elif etype == "extra": + # Collapse repeated extra entries (same name+sub) → (name, sub, qty) + existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None) + if existing: + result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1) + else: + result["extra"].append((name, sub, 1)) + else: + # Legacy data without type tag — treat as unknown, display plainly + if name: + result["unknown"].append(name + (f" · {sub}" if sub else "")) + + i += 1 + + return result def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session): - fonts = _load_print_fonts(db) - div = fonts["print.divider_style"] + cfg = _load_print_settings(db) + mode = cfg.get("print.ticket_mode", "detailed") + div = cfg.get("print.divider_style", "dash") + compact = (mode == "compact") - sz_order, bold_order = _decode_font(fonts["print.font_order_number"]) - sz_table, bold_table = _decode_font(fonts["print.font_table"]) - sz_item, bold_item = _decode_font(fonts["print.font_item_name"]) - sz_opt, bold_opt = _decode_font(fonts["print.font_options"]) + sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"]) + sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"]) + sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"]) + sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"]) + sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"]) + sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"]) + sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"]) + sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"]) + sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"]) - # Header — order number - p._raw(b'\x1b\x61\x01') - _font(p, sz_order, bold_order) - _raw_text(p, f"Παραγγελια #{order.id}\n") - p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x45\x00') - _divider(p, div) + # Resolve display names + table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id) + waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) + now_str = _greek_date(datetime.datetime.now()) - # Meta — table / waiter / time - p._raw(b'\x1b\x61\x00') - _font(p, sz_table, bold_table) - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") - _raw_text(p, f"Date: {now}\n") - _raw_text(p, f"Table: {order.table_id}\n") - _raw_text(p, f"Waiter: {order.opened_by}\n") - p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x45\x00') - _divider(p, div) + # ── COMPACT header — single line ──────────────────────────────────────── + if compact: + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_ord, b_ord) + header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}" + _raw_text(p, (header.upper() if c_ord else header) + "\n") + _reset_font(p) + _divider(p, div) + + # ── DETAILED header ────────────────────────────────────────────────────── + else: + _print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord, + align=b'\x1b\x61\x01') + _divider(p, div) + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_meta, b_meta) + _raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n") + _raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n") + _raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n") + _reset_font(p) + _divider(p, div) + + # ── Items ──────────────────────────────────────────────────────────────── + # Double-width fonts halve the effective character width + item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH - # Items for item in items: product = db.query(Product).filter(Product.id == item.product_id).first() - name = product.name if product else f"Product #{item.product_id}" + raw_name = product.name if product else f"Product #{item.product_id}" + item_name = raw_name.upper() if c_item else raw_name - _font(p, sz_item, bold_item) - _raw_text(p, _item_line(name, item.quantity) + "\n") - p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x45\x00') + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_item, b_item) + _raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n") + _reset_font(p) - _font(p, sz_opt, bold_opt) + opts = _parse_options(item) + + # Quick options (* marker) + if opts["quick"]: + if compact: + parts = [] + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + parts.append(f"{n} x{qty}" if qty > 1 else n) + _apply_font(p, sz_qk, b_qk) + _raw_text(p, "* " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + line = f"* {n} x{qty}" if qty > 1 else f"* {n}" + _apply_font(p, sz_qk, b_qk) + _raw_text(p, line + "\n") + _reset_font(p) + + # Preferences (> marker) + if opts["pref"]: + if compact: + parts = [] + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + parts.append(f"{n} · {s}" if s else n) + _apply_font(p, sz_pr, b_pr) + _raw_text(p, "> " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + line = f"> {n} · {s}" if s else f"> {n}" + _apply_font(p, sz_pr, b_pr) + _raw_text(p, line + "\n") + _reset_font(p) + + # Extras (+ marker) + if opts["extra"]: + if compact: + parts = [] + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + part = f"{n} · {s}" if s else n + if qty > 1: + part += f" · x{qty}" + parts.append(part) + _apply_font(p, sz_ex, b_ex) + _raw_text(p, "+ " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + line = f"+ {n}" + if s: + line += f" · {s}" + if qty > 1: + line += f" · x{qty}" + _apply_font(p, sz_ex, b_ex) + _raw_text(p, line + "\n") + _reset_font(p) + + # Legacy untagged options + for entry in opts["unknown"]: + _apply_font(p, sz_ex, b_ex) + _raw_text(p, f"+ {entry}\n") + _reset_font(p) + + # Removed ingredients (- marker) if item.removed_ingredients: try: - removed_ids = json.loads(item.removed_ingredients) - if removed_ids: - _raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n") - except (json.JSONDecodeError, TypeError): - pass - - if item.selected_options: - try: - option_ids = json.loads(item.selected_options) - if option_ids: - _raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n") + removed = json.loads(item.removed_ingredients) + if removed: + names = [n.upper() if c_ing else n for n in removed] + joined = " · ".join(names) + _apply_font(p, sz_ing, b_ing) + _raw_text(p, f"- ΧΩΡΙΣ: {joined}\n") + _reset_font(p) except (json.JSONDecodeError, TypeError): pass + # Per-item note if item.notes: - _raw_text(p, f" (i) {item.notes}\n") + note_text = item.notes.upper() if c_note else item.notes + _apply_font(p, sz_note, b_note) + if compact: + _raw_text(p, f"! {note_text}\n") + else: + _raw_text(p, f"\n(!) {note_text}\n\n") + _reset_font(p) - p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x45\x00') + # Blank line between items in detailed mode + if not compact: + p._raw(b'\n') _divider(p, div) + # Order-level notes if order.notes: - p._raw(b'\x1b\x21\x30') - _raw_text(p, "Σημειωσεις:\n") - p._raw(b'\x1b\x21\x10') - _raw_text(p, f"{order.notes}\n") - p._raw(b'\x1b\x21\x00') - _divider(p) + note_text = order.notes.upper() if c_onote else order.notes + _apply_font(p, sz_onote, b_onote) + _raw_text(p, f"Σημ: {note_text}\n") + _reset_font(p) + if not compact: + _divider(p, div) + + # Footer (detailed only) + if not compact: + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "Τελος Παραγγελιας\n") + p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x61\x01') - p._raw(b'\x1b\x21\x30') - _raw_text(p, "Τελος Παραγγελιας\n") - p._raw(b'\x1b\x21\x00') p._raw(b'\n\n\n') p.cut() diff --git a/local_backend/services/sse_bus.py b/local_backend/services/sse_bus.py new file mode 100644 index 0000000..6680f6c --- /dev/null +++ b/local_backend/services/sse_bus.py @@ -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": "", "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 diff --git a/manager_dashboard/src/pages/OrderDetailPage.jsx b/manager_dashboard/src/pages/OrderDetailPage.jsx index 44718ff..f210347 100644 --- a/manager_dashboard/src/pages/OrderDetailPage.jsx +++ b/manager_dashboard/src/pages/OrderDetailPage.jsx @@ -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 (
- {order.audit_logs.map(log => ( -
-
- - {EVENT_LABELS[log.event_type] ?? log.event_type} - + {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 ( +
+
+ + {EVENT_LABELS[log.event_type] ?? log.event_type} + + {isDuplicate && ( + ΔΙΠΛΗ + )} +
+
+ {log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`} + {log.amount != null && ( + + €{log.amount.toFixed(2)} + + )} + {log.payment_method && ( + ({log.payment_method}) + )} +
+
+ {displayTime} + {log.offline_at && ( + offline + )} +
-
- {log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`} - {log.amount != null && ( - €{log.amount.toFixed(2)} - )} - {log.payment_method && ( - ({log.payment_method}) - )} -
- {formatDate(log.created_at)} -
- ))} + ) + })}
) } diff --git a/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx b/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx index 3032623..a0be4c1 100644 --- a/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx +++ b/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx @@ -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' }} /> ))}
+
+ Χρώμα γραφής: + {[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => ( + + ))} +
@@ -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' }} /> ))}
+
+ {[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => ( + + ))} +
+ ) +} - function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold)) } - function handleBold() { onChange(field.key, encodeFont(size, !bold)) } +// ── Single font row ──────────────────────────────────────────────────────── +function FontRow({ field, value, onChange, isPending, nested = false }) { + const { size, bold, caps } = decodeFont(value) + + function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) } + function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) } + function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) } return (
+ {nested && ( + + )} {/* Label */}
- + {field.label} - {field.sub} + {field.sub && ( + {field.sub} + )}
{/* Size dropdown */} @@ -123,31 +153,28 @@ function FontRow({ field, value, onChange, isPending }) { {/* Bold toggle */} - + + + {/* Caps toggle */} + {/* Preview */} - + +
+ ) +} + +// ── Subgroup header row ──────────────────────────────────────────────────── +function SubgroupHeader({ label }) { + return ( +
+ + {label} +
) } @@ -181,10 +208,10 @@ function DividerRow({ value, onChange, isPending }) { ))} - {/* spacer to align with bold button column */} -
+ {/* spacer to align with bold+caps column */} +
- {/* Preview — same fixed size as font previews */} + {/* Preview */}
{ + 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 ( +
+
+

Τύπος Εκτύπωσης

+

+ Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας. +

+
+
+ {[ + { + key: 'detailed', + title: 'Αναλυτικό', + desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.', + }, + { + key: 'compact', + title: 'Συμπαγές', + desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.', + }, + ].map(opt => { + const active = value === opt.key + return ( + + ) + })} + + {/* Test order button */} + +
+
+ ) +} + // ── Printers section ─────────────────────────────────────────────────────── const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }] - const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true } function PrinterForm({ initial, onSave, onCancel, isPending }) { @@ -284,7 +428,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending } opacity: printer.is_active ? 1 : 0.5, flexWrap: 'wrap', }}> - {/* Enable/disable toggle */} - {/* Name + connection info */}
{printer.name} @@ -306,7 +448,6 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending } — {printer.protocol}
- {/* Reachability badge */} - {/* Actions */} +
+ +

+ Αυτόματη επανάληψη κάθε 10 δευτερόλεπτα +

+
+
+ ) +} diff --git a/waiter_pwa/src/components/EmergencyBar.jsx b/waiter_pwa/src/components/EmergencyBar.jsx new file mode 100644 index 0000000..5d27e1c --- /dev/null +++ b/waiter_pwa/src/components/EmergencyBar.jsx @@ -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 ( +
+ EMERGENCY MODE + {elapsed && ( + ({elapsed}) + )} +
+ ) +} diff --git a/waiter_pwa/src/components/ItemOptionsModal.jsx b/waiter_pwa/src/components/ItemOptionsModal.jsx index f3d3e2b..d10040f 100644 --- a/waiter_pwa/src/components/ItemOptionsModal.jsx +++ b/waiter_pwa/src/components/ItemOptionsModal.jsx @@ -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 }) diff --git a/waiter_pwa/src/components/OrderDrawer.jsx b/waiter_pwa/src/components/OrderDrawer.jsx index de796df..252b018 100644 --- a/waiter_pwa/src/components/OrderDrawer.jsx +++ b/waiter_pwa/src/components/OrderDrawer.jsx @@ -715,9 +715,9 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt && (!sharedSub || sharedSub.name === defaultSharedSub?.name) if (isFullyDefault) return [] - const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }] - if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 }) - if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 }) + const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }] + if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' }) + if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' }) return entries }) @@ -727,8 +727,8 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt const sub = opt.sub_choices?.find(s => s.name === sel.subName) const entries = [] for (let i = 0; i < sel.qty; i++) { - entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }) - if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 }) + entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' }) + if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' }) } return entries }) @@ -736,7 +736,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt const quickEntries = quickOptions.flatMap(opt => { const q = quickState[opt.id] || 0 if (q === 0) return [] - return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 })) + return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' })) }) const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name) diff --git a/waiter_pwa/src/components/ProductPicker.jsx b/waiter_pwa/src/components/ProductPicker.jsx index 9fef5c1..ccfbe76 100644 --- a/waiter_pwa/src/components/ProductPicker.jsx +++ b/waiter_pwa/src/components/ProductPicker.jsx @@ -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 (
-
- -
-
-
{topLevel.map(cat => { const isActive = activeCat === cat.id diff --git a/waiter_pwa/src/components/TableCard.jsx b/waiter_pwa/src/components/TableCard.jsx index a11fc01..e94beba 100644 --- a/waiter_pwa/src/components/TableCard.jsx +++ b/waiter_pwa/src/components/TableCard.jsx @@ -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 ( + {displayName} + ) + } + return ( +
{initials}
+ ) +} + +// 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 ( +
+ {waiters.map((w, i) => { + const name = w.nickname || w.full_name || w.username || '?' + return ( +
+ {i > 0 && ·} + + {name} +
+ ) + })} +
+ ) + } + + // > 2 waiters: icons only + "X Waiters" label + return ( +
+ {waiters.slice(0, 3).map((w, i) => ( +
+ +
+ ))} + {waiters.length > 3 && ( +
+{waiters.length - 3}
+ )} + + {waiters.length} σερβιτόροι + +
+ ) +} + +// ─── Status pill ────────────────────────────────────────────────────────────── + +function StatusPill({ label, badgeBg, badgeText, small }) { + return ( + {label} + ) +} + +// ─── Flag dot ───────────────────────────────────────────────────────────────── + +function FlagDot({ flag, size = 22 }) { + const textColor = flag.text_color || '#ffffff' + return ( +
+ {flag.emoji || '🏷️'} +
+ ) +} + +// ─── 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 ( +
+ {visible.map(f => )} + {overflow > 0 && ( +
+{overflow}
+ )} +
+ ) +} + +// ─── Flag chip (icon + label) ───────────────────────────────────────────────── + +function FlagChip({ flag }) { + const textColor = flag.text_color || '#ffffff' + return ( +
+ {flag.emoji || '🏷️'} + + {flag.name} + +
+ ) +} + +// ─── 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 ( +
+ {whole} + .{cents}€ +
+ ) +} + +// ─── 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 ( +
+ {/* top strip: badges up to 2, then +N */} +
+ +
+ + {/* center: name */} +
+ {table.label || `T${table.number}`} +
+ + {/* bottom strip: status */} +
+ + {STATUS_LABELS[statusKey]} + +
+
+ ) +} + +// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right. +function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) { + return ( +
+
+ {table.label || `T${table.number}`} +
+ +
+ + {flags.length > 0 && ( + + )} +
+
+ ) +} + +// 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 ( +
+ {/* left column */} +
+ + {table.label || `T${table.number}`} + +
+ +
+
+ {showAmount && } +
+
+ + {/* right column: flags — show 2, then +N */} + {flags.length > 0 && ( +
+ +
+ )} +
+ ) +} + +// 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 ( +
+ {/* name */} +
+ {table.label || `T${table.number}`} +
+ + {/* separator dot */} + · + + {/* amount */} +
+ {showAmount && } +
+ + {/* flags up to 3 + +N */} + {flags.length > 0 && ( + + )} + + {/* status */} + +
+ ) +} + +// 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 ( +
+ {/* main body */} +
+ {/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */} +
+ {/* left: name + zone */} +
+
+ {table.label || `T${table.number}`} +
+ {groupName && ( +
+ {groupName} +
+ )} +
+ + {/* center: status pill — top-aligned via paddingTop to optically align with name cap */} +
+ +
+ + {/* right: amount — top-aligned */} + {showAmount && ( +
+ +
+ )} +
+ + {/* flag chips row — right-aligned */} + {flags.length > 0 && ( +
+ {flags.slice(0, 4).map(f => )} + {flags.length > 4 && ( +
+{flags.length - 4}
+ )} +
+ )} +
+ + {/* footer: waiters */} +
+ {showWaiters + ? + : + } +
+
+ ) +} + +// 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 ( +
+
+ {/* left column: name, zone, amount, status, flags */} +
+
+
+ {table.label || `T${table.number}`} +
+ {groupName && ( +
+ {groupName} +
+ )} +
+ +
+ {!isFree && } +
+ +
+ +
+ + {flags.length > 0 && ( +
+ +
+ )} +
+ + {/* divider */} +
+ + {/* right column: order items */} +
+ {isFree ? ( +
+ Ελεύθερο +
+ ) : activeItems.length === 0 ? ( +
+ Κανένα είδος +
+ ) : ( +
+ {activeItems.slice(0, 7).map(item => ( +
+ {item.quantity}× + {item.product?.name || `#${item.product_id}`} + + {(item.unit_price * item.quantity).toFixed(2)}€ + +
+ ))} + {activeItems.length > 7 && ( +
+ +{activeItems.length - 7} ακόμα… +
+ )} +
+ )} +
+
+ + {/* footer: waiters */} +
+ {showWaiters + ? + : + } +
+
+ ) +} + +// ─── 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 ( -
+
- {/* Flag name tooltip on long-press (only when no onLongPress handler) */} {showTip && flags.length > 0 && (
{flags.map(f => (
diff --git a/waiter_pwa/src/components/UserMenu.jsx b/waiter_pwa/src/components/UserMenu.jsx index cc1d8c3..32c79ed 100644 --- a/waiter_pwa/src/components/UserMenu.jsx +++ b/waiter_pwa/src/components/UserMenu.jsx @@ -168,6 +168,12 @@ export default function UserMenu() { {dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'} + {/* ── Settings ──────────────────────────────────────── */} + +
{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'} - {/* Cart icon with badge — opens side drawer */} - +
+ {/* Search button */} + + {/* Categories button */} + + {/* Cart button with badge */} + +
{/* Product picker takes all remaining space */} {categories.length > 0 && ( - + )} {/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */} @@ -382,17 +410,12 @@ export default function AddItemsPage() { className="btn btn--primary btn--lg" style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }} onClick={sendOrder} - disabled={cart.length === 0 || sending} + disabled={cart.length === 0 || sending || !!printAck?.allOk} > {sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`} {error &&

{error}

} - {printAck?.allOk && ( -
- ✓ Εκτυπώθηκε επιτυχώς — μεταφορά… -
- )}
{/* ── Cart side drawer ────────────────────────────────────────────────── */} @@ -465,7 +488,7 @@ export default function AddItemsPage() { className="btn btn--primary btn--lg" style={{ width: '100%' }} onClick={sendOrder} - disabled={cart.length === 0 || sending} + disabled={cart.length === 0 || sending || !!printAck?.allOk} > {sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`} @@ -483,6 +506,46 @@ export default function AddItemsPage() { initialState={editItem.drawerState} /> )} + + {/* ── Search modal ─────────────────────────────────────────────────────── */} + {searchOpen && ( + setSearchOpen(false)} + onAdd={item => { addToCart(item); setSearchOpen(false) }} + /> + )} + + {/* Full-screen success overlay — blocks all interaction while navigating */} + {printAck?.allOk && ( +
+
+ + + + + + Εκτυπώθηκε Επιτυχώς + +
+ +
+ )}
) } @@ -638,3 +701,144 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
) } + +// ── 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 */} +
+ + {/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't + push the input off screen on short viewports */} +
+ {/* Results scroll area — flex:1 so it takes space above the input */} +
+ {query.trim().length === 0 ? ( +

+ Πληκτρολογήστε για αναζήτηση… +

+ ) : results.length === 0 ? ( +

+ Δεν βρέθηκαν προϊόντα για «{query}» +

+ ) : results.map(p => { + const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase() + return ( + + ) + })} +
+ + {/* Search input — pinned at the bottom of the panel, above the keyboard */} +
+ + + + + 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', + }} + /> + +
+
+ + {/* Product drawer — closes search modal when item is added */} + {drawerProduct && ( + setDrawerProduct(null)} + onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }} + /> + )} + + ) +} diff --git a/waiter_pwa/src/pages/LoginPage.jsx b/waiter_pwa/src/pages/LoginPage.jsx index 92024cd..64f01cf 100644 --- a/waiter_pwa/src/pages/LoginPage.jsx +++ b/waiter_pwa/src/pages/LoginPage.jsx @@ -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() {
{loadingWaiters ? (

Φόρτωση…

+ ) : serverUnreachable ? ( +
+
🔌
+

+ Δεν βρέθηκε ο Server +

+

+ Δεν είναι δυνατή η σύνδεση με τον Manager.
+ Δεν μπορεί να ξεκινήσει βάρδια χωρίς σύνδεση. +

+ +
) : waiters.length === 0 ? (

Δεν βρέθηκαν σερβιτόροι

) : ( diff --git a/waiter_pwa/src/pages/SettingsPage.jsx b/waiter_pwa/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..6dfd2c0 --- /dev/null +++ b/waiter_pwa/src/pages/SettingsPage.jsx @@ -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: , + }, + { + key: '2x1', + label: '2×1', + desc: '2 ανά σειρά — όνομα + κατάσταση', + preview: , + }, + { + key: '2x2', + label: '2×2', + desc: '2 ανά σειρά — συμπαγής κάρτα', + preview: , + }, + { + key: '4x1', + label: '4×1', + desc: '1 ανά σειρά — οριζόντια λίστα', + preview: , + }, + { + key: '4x2', + label: '4×2', + desc: '1 ανά σειρά — πλήρης κάρτα', + preview: , + }, + { + key: '4x3', + label: '4×3', + desc: '1 ανά σειρά — κάρτα με λίστα παραγγελίας', + preview: , + }, +] + +// ─── Mini grid preview SVGs ─────────────────────────────────────────────────── + +function Grid4() { + return ( + + {[0,1,2,3].map(i => ( + + ))} + {[0,1,2,3].map(i => ( + + ))} + {[0,1,2,3].map(i => ( + + ))} + + ) +} + +function Grid2H() { + return ( + + {[0,1].map(i => ( + + ))} + {[0,1].map(i => ( + + ))} + {[0,1].map(i => ( + + ))} + + ) +} + +function Grid2() { + return ( + + + + + + + ) +} + +function Grid1H() { + return ( + + + + + + ) +} + +function Grid1() { + return ( + + + + + ) +} + +function Grid1Detail() { + return ( + + + {/* left section lines */} + + + + {/* vertical divider */} + + {/* right section lines */} + + + + + + ) +} + +// ─── Layout tab ─────────────────────────────────────────────────────────────── + +function LayoutTab() { + const { density, setDensity } = useTableViewStore() + const { dark, toggle } = useThemeStore() + + return ( +
+ + {/* Card density */} +
+

Κάρτες τραπεζιών

+

Επίλεξε πόσα στοιχεία εμφανίζονται σε κάθε κάρτα.

+ +
+ {DENSITY_OPTIONS.map(opt => { + const active = density === opt.key + return ( + + ) + })} +
+
+ + {/* Theme */} +
+

Θέμα

+ +
+ {[ + { key: false, icon: '☀️', label: 'Φωτεινό' }, + { key: true, icon: '🌙', label: 'Σκοτεινό' }, + ].map(opt => { + const active = dark === opt.key + return ( + + ) + })} +
+
+
+ ) +} + +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 ( +
+ +

Σύντομα διαθέσιμο

+

+ Τα αγαπημένα προϊόντα θα εμφανίζονται εδώ για γρήγορη παραγγελία. +

+
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function SettingsPage() { + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState('layout') + + return ( +
+ {/* Top bar */} +
+ + Ρυθμίσεις + {/* spacer to balance the back button */} +
+
+ + {/* Tab strip */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* Tab body */} +
+ {activeTab === 'layout' && } + {activeTab === 'favorites' && } +
+
+ ) +} + diff --git a/waiter_pwa/src/pages/TableListPage.jsx b/waiter_pwa/src/pages/TableListPage.jsx index 257366d..f6ca1e6 100644 --- a/waiter_pwa/src/pages/TableListPage.jsx +++ b/waiter_pwa/src/pages/TableListPage.jsx @@ -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 ( + + + + ) +} + +// ─── Notification drawer ────────────────────────────────────────────────────── + +function NotificationDrawer({ messages, onClose }) { return (
e.stopPropagation()} style={{ maxHeight: '80svh' }}> @@ -37,9 +49,7 @@ function NotificationDrawer({ messages, onClose, onAck }) { 📢
{msg.sender_name && ( -
- {msg.sender_name} -
+
{msg.sender_name}
)}
{msg.body}
{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 (
- {/* Status overview card */}
e.stopPropagation()}> -
+
{tableName} {statusLabel}
- {order ? (
@@ -116,7 +119,6 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) ) : (

Δεν υπάρχει ενεργή παραγγελία

)} - {flags.length > 0 && (
{flags.map(f => ( @@ -132,47 +134,24 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) ))}
)} - -
- - {/* Quick actions card */} -
-

- ACTIONS -

+
+

ACTIONS

{QUICK_ACTIONS.map((a, i) => { const disabled = !order && a.key !== 'flags' return ( - + +
+
+
+ ) +} + +// ─── 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 ( +
+
e.stopPropagation()} + style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }} + > +
+ +
+ Φίλτρα + {hasActiveFilters && ( + + )} +
+ + {/* Owner: ALL | MINE */} +
+

Ανάθεση

+
+ {[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => ( + + ))} +
+
+ + {/* Status: ALL | FREE | OPEN | PAID */} +
+

Κατάσταση

+
+ {[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => ( + + ))} +
+
+ + {/* Zones: multi-select, one segmented container per zone */} + {groups.length > 0 && ( +
+

Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}

+
+ {groups.map(g => { + const active = zoneFilter.includes(g.id) + return ( +
+ +
+ ) + })} +
+
+ )} + + +
+
+ ) +} + +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', }} > - - - - + + + + {(unreadCount || 0) > 0 && ( @@ -319,109 +598,135 @@ export default function TableListPage() { - {offline && } + {isEmergency ? : (offline && )} -
- {FILTERS.map(f => ( - + {/* ── Zone tab bar ─────────────────────────────────────────────────────── */} +
+ {/* ALL tab */} + setActiveZoneTab('all')} + /> + + {/* Per-zone tabs */} + {visibleGroups.map(g => ( + setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)} + /> ))} - -
- - {zoneOpen && ( -
- - {groups.map(g => ( - - ))} - {tables.some(t => !t.group_id) && ( - - )} -
- )} -
-
-
+ {/* ── Table grid ───────────────────────────────────────────────────────── */} +
+ {/* Pull-to-refresh indicator */} + {(pulling || refreshing) && ( +
+ {refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'} +
+ )} + +
{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 ( navigate(destination)} - onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })} + waiterObjects={orderWaiters} + density={density} + onClick={handleClick} + onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })} /> ) })}
- -
+ {/* ── Filter FAB ───────────────────────────────────────────────────────── */} + + + {/* ── Modals ────────────────────────────────────────────────────────────── */} {showNotifs && ( - setShowNotifs(false)} - onAck={ackMessage} - /> + setShowNotifs(false)} /> + )} + + {showFilters && ( + setShowFilters(false)} anchorRef={filterBtnRef} /> )} {quickModal && ( @@ -434,6 +739,43 @@ export default function TableListPage() { onAction={(key) => handleQuickAction(quickModal.table.id, key)} /> )} + + {emergencyPayModal && ( + setEmergencyPayModal(null)} + onPay={handleEmergencyPay} + /> + )}
) } + +// ─── Zone tab pill ──────────────────────────────────────────────────────────── + +function ZoneTab({ label, color, active, onClick }) { + return ( + + ) +} diff --git a/waiter_pwa/src/services/offlinePayments.js b/waiter_pwa/src/services/offlinePayments.js new file mode 100644 index 0000000..f959c47 --- /dev/null +++ b/waiter_pwa/src/services/offlinePayments.js @@ -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 +} diff --git a/waiter_pwa/src/store/connectionStore.js b/waiter_pwa/src/store/connectionStore.js new file mode 100644 index 0000000..e9262ac --- /dev/null +++ b/waiter_pwa/src/store/connectionStore.js @@ -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 diff --git a/waiter_pwa/src/store/tableViewStore.js b/waiter_pwa/src/store/tableViewStore.js new file mode 100644 index 0000000..3e2c76c --- /dev/null +++ b/waiter_pwa/src/store/tableViewStore.js @@ -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 diff --git a/waiter_pwa/vite.config.js b/waiter_pwa/vite.config.js index 6be36ea..105d5e6 100644 --- a/waiter_pwa/vite.config.js +++ b/waiter_pwa/vite.config.js @@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ server: { - allowedHosts: 'all', + allowedHosts: ['all','pos-waiter.bonamin.gr'], }, plugins: [ react(),