From 8e27b7666e1b8065cbe5a06cfec940afd0448266 Mon Sep 17 00:00:00 2001 From: bonamin Date: Thu, 30 Apr 2026 16:58:13 +0300 Subject: [PATCH] general fixes and ordering display overhaul --- PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md | 85 +++ local_backend/main.py | 4 + local_backend/models/printer.py | 1 + local_backend/models/product.py | 1 + local_backend/print_test.py | 508 ++++++++++-------- local_backend/routers/products.py | 2 + local_backend/routers/settings.py | 13 + local_backend/routers/system.py | 25 +- local_backend/schemas/printer.py | 9 + local_backend/schemas/product.py | 2 + local_backend/services/printer_service.py | 89 ++- manager_dashboard/src/pages/ProductsTab.jsx | 10 +- .../src/pages/Settings/SettingsPage.jsx | 3 + .../src/pages/Settings/tabs/PrintFontsTab.jsx | 493 +++++++++++++++++ waiter_pwa/dev-dist/sw.js | 2 +- waiter_pwa/src/components/OrderDrawer.jsx | 87 ++- waiter_pwa/src/components/OrderSummary.jsx | 224 +++++++- waiter_pwa/src/pages/AddItemsPage.jsx | 136 +++-- waiter_pwa/src/pages/TableDetailPage.jsx | 111 +++- 19 files changed, 1470 insertions(+), 335 deletions(-) create mode 100644 PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md create mode 100644 manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx diff --git a/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md b/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md new file mode 100644 index 0000000..39b36cf --- /dev/null +++ b/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md @@ -0,0 +1,85 @@ +# Printer Beep Strategy + +## How beeping works on the Jolimark TP850UE + +The printer has a built-in buzzer. Three commands are available: + +| Command | Bytes | Parameters | Notes | +|---------|-------|------------|-------| +| `BEL` | `0x07` | none | Single short beep, ~50ms. Simplest. | +| `ESC BEL n1 n2 n3` | `0x1B 0x07 n1 n2 n3` | n1=on-time (×100ms), n2=off-time (×100ms), n3=count | Full control over length, gap, repetitions. | +| `GS BEL n1 n2 n3` | `0x1D 0x07 n1 n2 n3` | n1=count, n2=on-time (×100ms), n3=off-time (×100ms) | Same as ESC BEL but parameter order differs. | + +**Confirmed working on our test:** `ESC BEL` with `n1=2, n2=2, n3=1` = one 200ms beep. +Pattern beeps also work: `ESC BEL 1 1 3` = three short beeps in quick succession. + +The beep is triggered by sending these bytes **immediately before or after** the print job — +it does not need to be part of a complete print page. You can send a beep-only job +(connect, send beep bytes, close) without printing anything. + +--- + +## Where to add beeps in the system + +### 1. New kitchen ticket arrives (MOST IMPORTANT) +**Where:** `printer_service.py` → `_print_kitchen_ticket()`, just before or after the cut command. +**Pattern:** 2 short beeps — signals a new order without being annoying. +```python +p._raw(bytes([0x1b, 0x07, 1, 1, 2])) # 2× 100ms beeps +p.cut() +``` + +### 2. Re-print of an existing ticket +**Where:** Same function, but only 1 beep to distinguish from a new order. +```python +p._raw(bytes([0x1b, 0x07, 1, 2, 1])) # 1× 100ms beep +``` + +### 3. Urgent / rush order (future feature) +**Where:** If we add an "urgent" flag to orders, trigger a longer or triple beep. +```python +p._raw(bytes([0x1b, 0x07, 3, 1, 3])) # 3× 300ms beeps +``` + +### 4. Test print +**Where:** `send_test_print()` — already sends a test page, add 1 beep so the cook knows +to look at the printer. + +--- + +## Implementation plan (when ready) + +1. **Add a per-printer setting:** `print.beep_on_ticket` = `true`/`false` + (some stations may not want beeping, e.g. a bar printer near customers) + +2. **Add a beep pattern setting:** `print.beep_pattern` = `single` / `double` / `triple` + +3. **In `_print_kitchen_ticket`:** After building the ticket, before `p.cut()`: + ```python + if beep_enabled: + p._raw(beep_bytes_for_pattern) + ``` + +4. **No separate beep job needed** — bake it into the ticket job. The buzzer fires + as the paper is cutting, which is the natural attention signal. + +--- + +## Settings keys to add (future) + +``` +print.beep_on_ticket "true" / "false" default: "true" +print.beep_pattern "single" / "double" / "triple" default: "double" +``` + +These can go in the Printer management UI (per-printer toggle) and in the +Print Settings tab (global default pattern). + +--- + +## Notes + +- Beep bytes are sent over the same TCP socket as print data — no separate connection needed. +- The buzzer is hardware-limited; very short intervals (< 50ms) may be ignored. +- Beeping does NOT require paper to be loaded or printing to succeed — it fires independently. +- If spoof-printing mode is ON, the beep should also be suppressed (no real connection is made). diff --git a/local_backend/main.py b/local_backend/main.py index b0f54ec..05e5692 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -177,6 +177,10 @@ def _run_migrations(): "ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0", # Auto-expand flag for sub-categories on the PWA accordion "ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0", + # Printer protocol field + "ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'", + # Compact (half-width) display flag for quick options + "ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0", ] for sql in migrations: try: diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py index e609e51..8f8de7d 100644 --- a/local_backend/models/printer.py +++ b/local_backend/models/printer.py @@ -11,6 +11,7 @@ class Printer(Base): ip_address = Column(String, nullable=False) port = Column(Integer, default=9100, nullable=False) is_active = Column(Boolean, default=True, nullable=False) + protocol = Column(String, default="escpos_tcp", nullable=False) products = relationship("Product", back_populates="printer_zone") print_logs = relationship("PrintLog", back_populates="printer") diff --git a/local_backend/models/product.py b/local_backend/models/product.py index ea2436b..29c5463 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -72,6 +72,7 @@ class ProductQuickOption(Base): sort_order = Column(Integer, default=0, nullable=False) is_favorite = Column(Boolean, default=False, nullable=False) favorite_sort_order = Column(Integer, default=0, nullable=False) + is_compact = Column(Boolean, default=False, nullable=False) product = relationship("Product", back_populates="quick_options") diff --git a/local_backend/print_test.py b/local_backend/print_test.py index 325128d..a304eb4 100644 --- a/local_backend/print_test.py +++ b/local_backend/print_test.py @@ -1,305 +1,343 @@ """ -Printer font & symbol test script. -Usage (inside Docker): python print_test.py [IP] [PORT] -Defaults to 10.98.20.25:9100 +Printer comprehensive test script — Jolimark TP850UE +Usage: python print_test.py [IP] [PORT] +Default: 10.98.20.25:9100 + +Prints 6 pages: + Page 1 — ESC ! modes, Font A, English + Page 2 — ESC ! modes, Font B, English + Page 3 — ESC ! modes, Font A, Greek + Page 4 — ESC ! modes, Font B, Greek + Page 5 — GS ! character size multipliers (both fonts) + Page 6 — Beep tests + misc (underline, invert, symbols) + +ESC ! (0x1B 0x21 n) correct bit map for TP850UE: + Bit 0 (0x01) — Font B instead of Font A + Bit 3 (0x08) — Emphasize / Bold + Bit 4 (0x10) — Double-height + Bit 5 (0x20) — Double-width + Bit 7 (0x80) — Underline + +GS ! (0x1D 0x21 n) character size multiplier: + Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x) + High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x) + e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8 """ import sys +import time PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25" PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100 from escpos.printer import Network + +# ── Low-level helpers ────────────────────────────────────────────────────────── + def _gr(text: str) -> bytes: return text.encode('cp737', errors='replace') def _open(): p = Network(PRINTER_IP, PRINTER_PORT, timeout=10) - p._raw(b'\x1b\x40') # ESC @ reset - p._raw(b'\x1b\x74\x1d') # CP737 Greek code page + p._raw(b'\x1b\x40') # ESC @ — full reset + p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page return p def _t(p, text: str): p._raw(_gr(text)) +def _reset(p): + """Reset to: Font A, normal size, no bold, left-align.""" + p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A + p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal + p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size + p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off + p._raw(b'\x1b\x61\x00') # ESC a 0 — left align + +def _center(p): p._raw(b'\x1b\x61\x01') +def _left(p): p._raw(b'\x1b\x61\x00') + def _divider(p, char="-", width=48): - p._raw(b'\x1b\x61\x00') + _left(p) _t(p, char * width + "\n") -def _center(p): - p._raw(b'\x1b\x61\x01') +def _page_header(p, title: str): + _center(p) + p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28) + _t(p, title + "\n") + _reset(p) + _divider(p, "=") -def _left(p): - p._raw(b'\x1b\x61\x00') -# ── ESC ! n byte reference ────────────────────────────────────────────────── -# Bit 0 → underline (not tested, minor) -# Bit 1 → double-strike (bold) -# Bit 3 → double-height -# Bit 4 → double-width -# Bit 5 → delete-line -# Bit 7 → bold (ESC E alias in some models) -# Common combos used here: -# 0x00 = normal -# 0x08 = double-height only (48 chars wide) -# 0x10 = double-height only (alt bit) (48 chars wide) -# 0x18 = double-height + bold -# 0x20 = double-width only (24 chars wide) -# 0x30 = double-width + double-height (24 chars wide) -# 0x38 = double-width + double-height + bold -# 0x48 = double-height (bit 6 combo — some printers) +# ── ESC ! mode table ─────────────────────────────────────────────────────────── +# +# Each entry: (esc_bang_byte, esc_e_bold, label) +# esc_bang_byte sets the mode via ESC ! n +# esc_e_bold adds ESC E on top (independent bold layer) +# We test every useful combination so you can see the exact visual result. -MODES = [ - (0x00, "Normal (0x00)"), - (0x08, "Double-height bit3 (0x08)"), - (0x10, "Double-height bit4 (0x10)"), - (0x18, "Double-height + Bold (0x18)"), - (0x20, "Double-width (0x20)"), - (0x30, "Double-width + Double-height (0x30)"), - (0x38, "Double-width + Double-height + Bold (0x38)"), +ESC_BANG_MODES = [ + # (byte, extra_bold, label) + (0x00, False, "0x00 Normal"), + (0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"), + (0x08, False, "0x08 Bold only (bit3)"), + (0x10, False, "0x10 Double-height (bit4)"), + (0x10, True, "0x10 +ESC E Double-height + Bold"), + (0x18, False, "0x18 Double-height + Bold (bits 3+4)"), + (0x20, False, "0x20 Double-width (bit5)"), + (0x20, True, "0x20 +ESC E Double-width + Bold"), + (0x28, False, "0x28 Double-width + Bold (bits 3+5)"), + (0x30, False, "0x30 Double-width + Double-height (bits 4+5)"), + (0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"), ] -# ── Section 1 — Font sizes & styles, English ─────────────────────────────── -def section_english(p): - _center(p) - p._raw(b'\x1b\x21\x38') - _t(p, "=== FONT SIZES (EN) ===\n") - p._raw(b'\x1b\x21\x00') - _divider(p, "=") +def _esc_bang_section(p, english: bool): + lang = "EN" if english else "GR" + sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ" + sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης" - for code, label in MODES: + for (byte_val, extra_bold, label) in ESC_BANG_MODES: _left(p) - _t(p, f"[{label}]\n") - p._raw(bytes([0x1b, 0x21, code])) - _t(p, "TEST PRINT Hello World Abc123\n") + # Print the label in small normal text first p._raw(b'\x1b\x21\x00') - # bold on/off via ESC E - _t(p, " -> bold ON: ") - p._raw(b'\x1b\x45\x01') - p._raw(bytes([0x1b, 0x21, code])) - _t(p, "Bold Sample\n") p._raw(b'\x1b\x45\x00') - p._raw(b'\x1b\x21\x00') + _t(p, f"[{label}]\n") + + # Apply mode + p._raw(bytes([0x1b, 0x21, byte_val])) + if extra_bold: + p._raw(b'\x1b\x45\x01') + + _t(p, sample_normal + "\n") + _t(p, sample_lower + "\n") + + # Reset + _reset(p) _t(p, "\n") _divider(p) - p._raw(b'\n') -# ── Section 2 — Font sizes & styles, Greek ───────────────────────────────── -def section_greek(p): - _center(p) - p._raw(b'\x1b\x21\x38') - _t(p, "=== FONT SIZES (GR) ===\n") - p._raw(b'\x1b\x21\x00') - _divider(p, "=") +# ── Pages 1–4: ESC ! modes ──────────────────────────────────────────────────── - for code, label in MODES: +def page_esc_bang(font_b: bool, english: bool): + font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)" + lang_label = "GREEK" if not english else "ENGLISH" + p = _open() + + # Select font + p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00') + + _page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}") + _t(p, f"Font: {font_label}\n") + _divider(p) + + _esc_bang_section(p, english) + + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Page 5: GS ! size multipliers ───────────────────────────────────────────── + +# Combinations worth seeing: square multipliers + some asymmetric +GS_SIZES = [ + (0x00, "1x1 normal"), + (0x01, "1w x 2h"), + (0x10, "2w x 1h"), + (0x11, "2x2"), + (0x22, "3x3"), + (0x33, "4x4"), + (0x44, "5x5"), + (0x55, "6x6"), + (0x02, "1w x 3h"), + (0x20, "3w x 1h"), + (0x21, "3w x 2h"), + (0x12, "2w x 3h"), +] + +def page_gs_sizes(): + p = _open() + _page_header(p, "GS! SIZE MULTIPLIERS") + _t(p, "GS ! n (0x1D 0x21 n)\n") + _t(p, "Low nibble=height, High nibble=width\n") + _divider(p) + + for (byte_val, label) in GS_SIZES: _left(p) - _t(p, f"[{label}]\n") - p._raw(bytes([0x1b, 0x21, code])) - _t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n") - _t(p, "δοκιμαστικη εκτυπωση\n") # lowercase - p._raw(b'\x1b\x21\x00') - p._raw(b'\x1b\x45\x01') - p._raw(bytes([0x1b, 0x21, code])) - _t(p, "Bold: Καλημερα Κοσμε\n") - p._raw(b'\x1b\x45\x00') + # Label in tiny normal text p._raw(b'\x1b\x21\x00') + p._raw(b'\x1d\x21\x00') + _t(p, f"[n=0x{byte_val:02X} {label}]\n") + + # Font A sample + p._raw(b'\x1b\x4d\x00') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "Aa SAMPLE\n") + p._raw(b'\x1d\x21\x00') + + # Font B sample on same size + p._raw(b'\x1b\x4d\x01') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "Bb SMALL\n") + p._raw(b'\x1d\x21\x00') + p._raw(b'\x1b\x4d\x00') # back to Font A + _t(p, "\n") _divider(p) - p._raw(b'\n') - -# ── Section 3 — All printable ASCII symbols ──────────────────────────────── - -def section_ascii_symbols(p): - _center(p) - p._raw(b'\x1b\x21\x18') # double-height bold for header - _t(p, "=== ASCII SYMBOLS ===\n") - p._raw(b'\x1b\x21\x00') - _divider(p, "=") - _left(p) - - # Printable ASCII 0x20 – 0x7E, 16 per line - chars = [chr(c) for c in range(0x20, 0x7F)] - line = "" - for i, ch in enumerate(chars): - line += ch + " " - if (i + 1) % 24 == 0: - _t(p, line + "\n") - line = "" - if line: - _t(p, line + "\n") + # Also show GS ! combined with ESC ! bold _t(p, "\n") - _t(p, "Notable:\n") - _t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n") - _t(p, " Framing : [ ] { } ( ) = : ; , . \"\n") - _t(p, " Currency : $ %\n") - _divider(p) - p._raw(b'\n') - -# ── Section 4 — CP737 extended chars (0x80–0xFF) ─────────────────────────── - -def section_extended(p): - _center(p) - p._raw(b'\x1b\x21\x18') - _t(p, "=== CP737 EXTENDED (0x80-FF) ===\n") - p._raw(b'\x1b\x21\x00') _divider(p, "=") - _left(p) - _t(p, "Hex offset rows x16 columns\n\n") - - for row in range(8): # 0x80, 0x90 ... 0xF0 - base = 0x80 + row * 16 - row_bytes = bytes(range(base, base + 16)) - label = f"0x{base:02X}: " - p._raw(_gr(label)) - p._raw(row_bytes) - p._raw(b'\n') - - _t(p, "\n") - _t(p, "Key CP737 specials:\n") - specials = [ - (0xB3, "─ thin horiz line"), - (0xC4, "─ double line"), - (0xBA, "│ vert line"), - (0xBB, "┐ corner"), - (0xBC, "┘ corner"), - (0xC9, "╔ corner"), - (0xCA, "╩ junction"), - (0xCB, "╦ junction"), - (0xCC, "╠ junction"), - (0xCD, "═ double horiz"), - (0xCE, "╬ cross"), - (0xC8, "╚ corner"), - (0xBB, "╗ corner"), - (0xBC, "╝ corner"), - (0xDB, "█ full block"), - (0xDC, "▄ lower block"), - (0xDF, "▀ upper block"), - (0xB0, "░ light shade"), - (0xB1, "▒ medium shade"), - (0xB2, "▓ dark shade"), - (0xF8, "° degree"), - (0xF9, "· middle dot"), - (0xFA, "· bullet dot"), - (0xFB, "√ check / tick"), - (0xFE, "■ filled square"), - ] - for code, desc in specials: - row_bytes = bytes([code, 0x20]) # char + space - p._raw(row_bytes) - _t(p, f" {desc}\n") - - _divider(p) - p._raw(b'\n') - -# ── Section 5 — Underline ────────────────────────────────────────────────── - -def section_underline(p): - _center(p) - p._raw(b'\x1b\x21\x18') - _t(p, "=== UNDERLINE TEST ===\n") p._raw(b'\x1b\x21\x00') + _t(p, "GS! + ESC E bold combined:\n") _divider(p, "=") - _left(p) + for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]: + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1d\x21\x00') + _t(p, f"[{label} + bold]\n") + p._raw(b'\x1b\x45\x01') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "BOLD LARGE\n") + p._raw(b'\x1b\x45\x00') + p._raw(b'\x1d\x21\x00') + _t(p, "\n") - # ESC - n : underline 0=off, 1=thin, 2=thick + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Page 6: Beep + misc ──────────────────────────────────────────────────────── + +def page_beep_misc(): + p = _open() + _page_header(p, "BEEP + MISC TESTS") + + # ── Beep section ── + _t(p, "BEEP TESTS\n") + _divider(p, "-") + _t(p, "Sending beeps now...\n\n") + + # BEL — single beep (0x07) + _t(p, "[1] BEL single beep (0x07)\n") + p._raw(b'\x07') + time.sleep(0.5) + + # ESC BEL n1 n2 n3 — beep for appointment + # n1=beep length (100ms units), n2=intermission (100ms), n3=count + _t(p, "[2] ESC BEL: 1 beep, 200ms long\n") + p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep + time.sleep(0.8) + + _t(p, "[3] ESC BEL: 3 short beeps\n") + p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps + time.sleep(1.5) + + _t(p, "[4] ESC BEL: 1 long beep (500ms)\n") + p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep + time.sleep(1.2) + + _t(p, "[5] GS BEL: 2 beeps\n") + p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off + time.sleep(1.5) + + _t(p, "Beep tests done.\n") + _divider(p) + + # ── Underline ── + _t(p, "\nUNDERLINE\n") + _divider(p, "-") for ul in [1, 2]: p._raw(bytes([0x1b, 0x2d, ul])) - _t(p, f"Underline mode {ul}: TEST PRINT Abc123\n") - p._raw(b'\x1b\x2d\x00') # off + _t(p, f"Underline mode {ul}: Hello World 123\n") + p._raw(b'\x1b\x2d\x00') _t(p, "\n") _divider(p) - p._raw(b'\n') -# ── Section 6 — Inverted / white-on-black ───────────────────────────────── - -def section_invert(p): - _center(p) - p._raw(b'\x1b\x21\x18') - _t(p, "=== INVERT (white-on-black) ===\n") - p._raw(b'\x1b\x21\x00') - _divider(p, "=") - _left(p) - - # GS B n — invert + # ── White-on-black invert ── + _t(p, "\nWHITE-ON-BLACK (GS B)\n") + _divider(p, "-") p._raw(b'\x1d\x42\x01') - _t(p, " INVERTED TEXT SAMPLE \n") + _t(p, " INVERTED NORMAL \n") + p._raw(b'\x1d\x21\x11') # 2x2 inverted + _t(p, " INVERTED 2x2 \n") + p._raw(b'\x1d\x21\x00') p._raw(b'\x1d\x42\x00') - _t(p, "Normal text after invert\n") - _t(p, "\n") + _t(p, "Normal after invert\n") _divider(p) - p._raw(b'\n') -# ── Section 7 — QR Code sample ──────────────────────────────────────────── - -def section_qr(p): - _center(p) - p._raw(b'\x1b\x21\x18') - _t(p, "=== QR CODE SAMPLE ===\n") - p._raw(b'\x1b\x21\x00') - _divider(p, "=") - - data = b"https://pos.test" - # GS ( k — QR store data - store_len = len(data) + 3 - p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data) - # GS ( k — set size (module=6) - p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06') - # GS ( k — error correction level M - p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31') - # GS ( k — print - p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30') - - _t(p, "\nhttps://pos.test\n\n") + # ── 90-degree rotation ── + _t(p, "\n90-DEGREE ROTATION (ESC V)\n") + _divider(p, "-") + p._raw(b'\x1b\x56\x01') + _t(p, "ROTATED TEXT\n") + p._raw(b'\x1b\x56\x00') + _t(p, "Normal again\n") _divider(p) - p._raw(b'\n') -# ── Main ─────────────────────────────────────────────────────────────────── + # ── CP737 useful symbols at normal size ── + _t(p, "\nUSEFUL CP737 SYMBOLS\n") + _divider(p, "-") + symbols = [ + (0xFB, "tick / checkmark"), + (0xFE, "filled square"), + (0xF9, "middle dot"), + (0xFA, "small bullet"), + (0xF8, "degree"), + (0xDB, "full block"), + (0xDC, "lower half block"), + (0xDF, "upper half block"), + (0xB0, "light shade"), + (0xB1, "medium shade"), + (0xB2, "dark shade"), + (0xC4, "thin horiz line"), + (0xCD, "double horiz line"), + (0xBA, "vertical bar"), + (0xC9, "top-left corner dbl"), + (0xBB, "top-right corner dbl"), + (0xC8, "bot-left corner dbl"), + (0xBC, "bot-right corner dbl"), + ] + for code, desc in symbols: + p._raw(bytes([code, 0x20, code, 0x20, code, 0x20])) + _t(p, f" {desc}\n") + + _divider(p) + + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Main ─────────────────────────────────────────────────────────────────────── def main(): - print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...") + print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}") + print("Printing 6 pages...\n") - # ---- PAGE 1: English fonts ---- - p = _open() - section_english(p) - p._raw(b'\n\n\n') - p.cut() - p.close() - print("Page 1 sent (English fonts)") + page_esc_bang(font_b=False, english=True) + print("Page 1 done — ESC! modes, Font A, English") - # ---- PAGE 2: Greek fonts ---- - p = _open() - section_greek(p) - p._raw(b'\n\n\n') - p.cut() - p.close() - print("Page 2 sent (Greek fonts)") + page_esc_bang(font_b=True, english=True) + print("Page 2 done — ESC! modes, Font B, English") - # ---- PAGE 3: Symbols & special chars ---- - p = _open() - section_ascii_symbols(p) - section_extended(p) - p._raw(b'\n\n\n') - p.cut() - p.close() - print("Page 3 sent (ASCII + CP737 extended)") + page_esc_bang(font_b=False, english=False) + print("Page 3 done — ESC! modes, Font A, Greek") - # ---- PAGE 4: Underline + Invert + QR ---- - p = _open() - section_underline(p) - section_invert(p) - section_qr(p) - p._raw(b'\n\n\n') - p.cut() - p.close() - print("Page 4 sent (underline / invert / QR)") + page_esc_bang(font_b=True, english=False) + print("Page 4 done — ESC! modes, Font B, Greek") - print("Done — 4 pages printed.") + page_gs_sizes() + print("Page 5 done — GS! size multipliers") + + page_beep_misc() + print("Page 6 done — Beep tests + misc") + + print("\nAll done.") if __name__ == "__main__": main() diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index 2915055..b7c542f 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -35,6 +35,7 @@ def _replace_quick_options(db, product, quick_options): sort_order=qo.sort_order if qo.sort_order else i, is_favorite=qo.is_favorite, favorite_sort_order=qo.favorite_sort_order, + is_compact=qo.is_compact, )) @@ -206,6 +207,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use sort_order=qo.sort_order if qo.sort_order else i, is_favorite=qo.is_favorite, favorite_sort_order=qo.favorite_sort_order, + is_compact=qo.is_compact, )) for opt in body.options: sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py index e15218f..933738a 100644 --- a/local_backend/routers/settings.py +++ b/local_backend/routers/settings.py @@ -17,6 +17,13 @@ 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.divider_style": "Divider character used between sections: dash, equals, star, or empty", } DEFAULTS = { @@ -26,6 +33,12 @@ 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.divider_style": "dash", } diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index f263303..939fad4 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -5,7 +5,7 @@ from typing import List from database import get_db from models.printer import Printer -from schemas.printer import PrinterUpdate, PrinterOut +from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut from routers.deps import get_current_user, require_manager, require_sysadmin from models.user import User from services import printer_service @@ -40,7 +40,16 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren @router.get("/printers", response_model=List[PrinterOut]) def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)): - return db.query(Printer).filter(Printer.is_active == True).all() + return db.query(Printer).all() + + +@router.post("/printers", response_model=PrinterOut) +def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = Printer(**body.model_dump()) + db.add(printer) + db.commit() + db.refresh(printer) + return printer @router.post("/printers/test") @@ -53,7 +62,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De @router.put("/printers/{printer_id}", response_model=PrinterOut) -def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)): +def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): printer = db.query(Printer).filter(Printer.id == printer_id).first() if not printer: raise HTTPException(status_code=404, detail="Printer not found") @@ -64,6 +73,16 @@ def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(g return printer +@router.delete("/printers/{printer_id}") +def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + db.delete(printer) + db.commit() + return {"ok": True} + + @router.post("/lock") def lock_system(token: str, user: User = Depends(require_sysadmin)): license_state["locked"] = True diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py index 6958b3e..9faf8f1 100644 --- a/local_backend/schemas/printer.py +++ b/local_backend/schemas/printer.py @@ -2,11 +2,19 @@ from pydantic import BaseModel from typing import Optional +PROTOCOLS = ["escpos_tcp"] # extend later as needed + + class PrinterBase(BaseModel): name: str ip_address: str port: int = 9100 is_active: bool = True + protocol: str = "escpos_tcp" + + +class PrinterCreate(PrinterBase): + pass class PrinterUpdate(BaseModel): @@ -14,6 +22,7 @@ class PrinterUpdate(BaseModel): ip_address: Optional[str] = None port: Optional[int] = None is_active: Optional[bool] = None + protocol: Optional[str] = None class PrinterOut(PrinterBase): diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 7dbdd99..50bb9eb 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -57,6 +57,7 @@ class ProductQuickOptionCreate(BaseModel): sort_order: int = 0 is_favorite: bool = False favorite_sort_order: int = 0 + is_compact: bool = False class ProductQuickOptionOut(BaseModel): @@ -68,6 +69,7 @@ class ProductQuickOptionOut(BaseModel): sort_order: int = 0 is_favorite: bool = False favorite_sort_order: int = 0 + is_compact: bool = False model_config = {"from_attributes": True} diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index 9dd1b09..c146c76 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -47,9 +47,57 @@ def _raw_text(p: Network, text: str): p._raw(_gr(text)) -def _divider(p: Network): +_DIVIDER_CHARS = { + "dash": "-", + "equals": "=", + "star": "*", + "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.divider_style": "dash", +} + +# SIZE byte values (ESC ! base, no bold bit): +# 0 = normal +# 16 = double-height (bit4) +# 32 = double-width (bit5) +# 48 = double-height + double-width (bits 4+5) +# Bold is applied separately via ESC E. + +def _decode_font(value: str) -> tuple[int, bool]: + """Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag).""" + try: + parts = str(value).split(":") + size = int(parts[0]) + bold = len(parts) > 1 and parts[1] == "1" + return size, bold + except (ValueError, AttributeError): + return 0, False + + +def _load_print_fonts(db: Session) -> dict: + rows = db.query(PosSettings).filter( + PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys()) + ).all() + fonts = dict(_PRINT_FONT_DEFAULTS) + for row in rows: + fonts[row.key] = row.value + return fonts + + +def _divider(p: Network, style: str = "dash"): + char = _DIVIDER_CHARS.get(style, "-") p._raw(b'\x1b\x61\x00') - p._raw(_gr("-" * LINE_WIDTH + "\n")) + if char: + p._raw(_gr(char * LINE_WIDTH + "\n")) + else: + p._raw(b'\n') def _item_line(name: str, qty: int) -> str: @@ -106,34 +154,50 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: # ── 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 _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session): - # Header + fonts = _load_print_fonts(db) + div = fonts["print.divider_style"] + + 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"]) + + # Header — order number p._raw(b'\x1b\x61\x01') - p._raw(b'\x1b\x21\x38') # bold + double height + double width + _font(p, sz_order, bold_order) _raw_text(p, f"Παραγγελια #{order.id}\n") p._raw(b'\x1b\x21\x00') - _divider(p) + p._raw(b'\x1b\x45\x00') + _divider(p, div) - # Meta + # Meta — table / waiter / time p._raw(b'\x1b\x61\x00') - p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width + _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') - _divider(p) + p._raw(b'\x1b\x45\x00') + _divider(p, div) # 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}" - p._raw(b'\x1b\x21\x10') - p._raw(b'\x1b\x45\x01') # bold on + _font(p, sz_item, bold_item) _raw_text(p, _item_line(name, item.quantity) + "\n") - p._raw(b'\x1b\x45\x00') # bold off + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1b\x45\x00') + _font(p, sz_opt, bold_opt) if item.removed_ingredients: try: removed_ids = json.loads(item.removed_ingredients) @@ -154,8 +218,9 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: _raw_text(p, f" (i) {item.notes}\n") p._raw(b'\x1b\x21\x00') + p._raw(b'\x1b\x45\x00') - _divider(p) + _divider(p, div) if order.notes: p._raw(b'\x1b\x21\x30') diff --git a/manager_dashboard/src/pages/ProductsTab.jsx b/manager_dashboard/src/pages/ProductsTab.jsx index fc430a1..53b106b 100644 --- a/manager_dashboard/src/pages/ProductsTab.jsx +++ b/manager_dashboard/src/pages/ProductsTab.jsx @@ -766,6 +766,7 @@ function buildFormFromProduct(product) { sort_order: q.sort_order ?? 0, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, + is_compact: q.is_compact ?? false, })) ?? [], options: product.options?.map(o => ({ name: o.name, @@ -906,7 +907,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo } // ── Quick Options ── - function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) } + function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) } function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) } function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) } function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) } @@ -1083,6 +1084,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo sort_order: i, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, + is_compact: q.is_compact ?? false, })), options: form.options.map(o => ({ name: o.name, @@ -1346,6 +1348,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo className="accent-primary-700 w-4 h-4" /> Πολλαπλά + ))} diff --git a/manager_dashboard/src/pages/Settings/SettingsPage.jsx b/manager_dashboard/src/pages/Settings/SettingsPage.jsx index 9445ac7..6729c48 100644 --- a/manager_dashboard/src/pages/Settings/SettingsPage.jsx +++ b/manager_dashboard/src/pages/Settings/SettingsPage.jsx @@ -2,10 +2,12 @@ import { useState } from 'react' import AppInfoTab from './tabs/AppInfoTab' import ColoursTab from './tabs/ColoursTab' import DevelopmentTab from './tabs/DevelopmentTab' +import PrintFontsTab from './tabs/PrintFontsTab' const TABS = [ { key: 'app-info', label: 'App Info' }, { key: 'colours', label: 'UI Personalization' }, + { key: 'print-fonts', label: 'Εκτύπωση' }, { key: 'development', label: 'Development' }, ] @@ -48,6 +50,7 @@ export default function SettingsPage() { {/* Tab content */} {activeTab === 'app-info' && } {activeTab === 'colours' && } + {activeTab === 'print-fonts' && } {activeTab === 'development' && } ) diff --git a/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx b/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx new file mode 100644 index 0000000..49649b8 --- /dev/null +++ b/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx @@ -0,0 +1,493 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../../../api/client' + +// ── Font option definitions ──────────────────────────────────────────────── +// Value encodes: "ESC_BANG_BYTE:BOLD" where BOLD is 0 or 1 +// ESC ! correct bit map for TP850UE: +// bit3 (0x08) = bold +// bit4 (0x10) = double-height +// bit5 (0x20) = double-width +const FONT_SIZE_OPTIONS = [ + { size: '0', label: 'Μικρά' }, + { size: '16', label: 'Ψηλά' }, + { size: '32', label: 'Πλατιά' }, + { size: '48', label: 'Ψηλά και Πλατιά' }, +] + +// We store the value as "SIZE:BOLD" e.g. "16:1" or "0:0" +function encodeFont(size, bold) { return `${size}:${bold ? '1' : '0'}` } +function decodeFont(val) { + if (!val) return { size: '0', bold: false } + const [size, bold] = val.split(':') + return { size: size ?? '0', bold: bold === '1' } +} + +const DIVIDER_OPTIONS = [ + { value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' }, + { value: 'equals', label: 'Ίσον ( = )', chars: '===================' }, + { value: 'star', label: 'Αστερίσκοι ( * )', chars: '*******************' }, + { value: 'empty', label: 'Κενή γραμμή', chars: '' }, +] + +const FONT_FIELDS = [ + { key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Κάθε πιάτο/ποτό στο ticket κουζίνας' }, + { key: 'print.font_options', label: 'Επιλογές / Τροποποιητές', sub: 'Extras, αφαιρέσεις, σημειώσεις' }, + { key: 'print.font_table', label: 'Τραπέζι & Σερβιτόρος', sub: 'Αριθμός τραπεζιού, όνομα σερβιτόρου, ώρα' }, + { key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: 'Η επικεφαλίδα "Παραγγελία #Χ"' }, + { key: 'print.font_header', label: 'Κεφαλίδα / Τίτλος', sub: 'Τίτλοι ενοτήτων, κεφαλίδες αναφορών' }, +] + +const FONT_DEFAULTS = { + '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.divider_style': 'dash', +} + +// ── Preview box ──────────────────────────────────────────────────────────── +// Fixed height tall enough for the largest option (Ψηλά και Πλατιά). +// All rows share the same height so columns stay aligned. +const PREVIEW_W = 200 +const PREVIEW_H = 50 + +const sizeStyle = { + '0': { fontSize: 13, scaleY: 1, scaleX: 1 }, + '16': { fontSize: 13, scaleY: 1.9, scaleX: 1 }, + '32': { fontSize: 13, scaleY: 1, scaleX: 1.9 }, + '48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 }, +} + +function FontPreview({ size, bold }) { + const s = sizeStyle[size] ?? sizeStyle['0'] + return ( +
+ + SAMPLE + +
+ ) +} + +// ── Single font row ──────────────────────────────────────────────────────── +function FontRow({ field, value, onChange, isPending }) { + const { size, bold } = decodeFont(value) + + function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold)) } + function handleBold() { onChange(field.key, encodeFont(size, !bold)) } + + return ( +
+ {/* Label */} +
+ + {field.label} + + {field.sub} +
+ + {/* Size dropdown */} + + + {/* Bold toggle */} + + + {/* Preview */} + +
+ ) +} + +// ── Divider row ──────────────────────────────────────────────────────────── +function DividerRow({ value, onChange, isPending }) { + return ( +
+
+ + Στυλ Διαχωριστικού + + Ανάμεσα στις ενότητες κάθε ticket +
+ + + + {/* spacer to align with bold button column */} +
+ + {/* Preview — same fixed size as font previews */} +
+ {value === 'empty' + ? (κενή γραμμή) + : + {DIVIDER_OPTIONS.find(o => o.value === value)?.chars} + + } +
+
+ ) +} + +// ── Printers section ─────────────────────────────────────────────────────── + +const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }] + +const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true } + +function PrinterForm({ initial, onSave, onCancel, isPending }) { + const [form, setForm] = useState(initial ?? EMPTY_FORM) + function set(k, v) { setForm(f => ({ ...f, [k]: v })) } + + return ( +
+
+ + set('name', e.target.value)} + placeholder="π.χ. Κουζίνα" style={inputStyle} /> +
+
+ + set('ip_address', e.target.value)} + placeholder="10.98.20.25" style={inputStyle} /> +
+
+ + set('port', parseInt(e.target.value) || 9100)} + type="number" style={inputStyle} /> +
+
+ + +
+
+ + +
+
+ ) +} + +const inputStyle = { + height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', + padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%', +} +const btnPrimary = { + height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', + border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', +} +const btnSecondary = { + height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', + background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151', +} +const btnDanger = { + height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', + background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626', +} + +function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) { + const [reachable, setReachable] = useState(null) + + useEffect(() => { + let cancelled = false + client.get('/api/system/status').then(r => { + if (cancelled) return + const match = r.data.printers?.find(p => p.id === printer.id) + if (match) setReachable(match.reachable) + }).catch(() => {}) + return () => { cancelled = true } + }, [printer.id]) + + return ( +
+ {/* Enable/disable toggle */} + + + {/* Name + connection info */} +
+ {printer.name} + + {printer.ip_address}:{printer.port} + + — {printer.protocol} +
+ + {/* Reachability badge */} + + {reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'} + + + {/* Actions */} + + + +
+ ) +} + +function PrintersSection() { + const qc = useQueryClient() + const [showNew, setShowNew] = useState(false) + const [editingId, setEditingId] = useState(null) + + const { data: printers = [], isLoading } = useQuery({ + queryKey: ['printers-all'], + queryFn: () => client.get('/api/system/printers').then(r => r.data), + staleTime: 15_000, + }) + + const createMut = useMutation({ + mutationFn: body => client.post('/api/system/printers', body), + onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) }, + onError: () => toast.error('Σφάλμα δημιουργίας'), + }) + const updateMut = useMutation({ + mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body), + onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) }, + onError: () => toast.error('Σφάλμα αποθήκευσης'), + }) + const deleteMut = useMutation({ + mutationFn: id => client.delete(`/api/system/printers/${id}`), + onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) }, + onError: () => toast.error('Σφάλμα διαγραφής'), + }) + const testMut = useMutation({ + mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`), + onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`), + onError: () => toast.error('Σφάλμα επικοινωνίας'), + }) + + function handleToggle(printer) { + updateMut.mutate({ id: printer.id, is_active: !printer.is_active }) + } + + return ( +
+
+
+

Εκτυπωτές

+

Διαχείριση εκτυπωτών του συστήματος

+
+ +
+ + {showNew && ( +
+ createMut.mutate(form)} + onCancel={() => setShowNew(false)} + isPending={createMut.isPending} + /> +
+ )} + + {isLoading &&

Φόρτωση…

} + {!isLoading && printers.length === 0 && !showNew && ( +

+ Δεν υπάρχουν εκτυπωτές ακόμα. +

+ )} + + {printers.map(printer => ( + editingId === printer.id ? ( +
+ updateMut.mutate({ id: printer.id, ...form })} + onCancel={() => setEditingId(null)} + isPending={updateMut.isPending} + /> +
+ ) : ( + { setEditingId(p.id); setShowNew(false) }} + onDelete={id => deleteMut.mutate(id)} + onTest={id => testMut.mutate(id)} + onToggle={handleToggle} + testPending={testMut.isPending} + /> + ) + ))} +
+ ) +} + +// ── Main tab ─────────────────────────────────────────────────────────────── +export default function PrintFontsTab() { + const qc = useQueryClient() + + const { data: settings, isLoading } = useQuery({ + queryKey: ['pos-settings'], + queryFn: () => client.get('/api/settings/').then(r => r.data), + staleTime: 30_000, + }) + + const updateMut = useMutation({ + mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }), + onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) }, + onError: () => toast.error('Σφάλμα αποθήκευσης'), + }) + + function val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] } + function handleChange(key, value) { updateMut.mutate({ key, value }) } + + if (isLoading) { + return
Φόρτωση…
+ } + + return ( +
+ + + + {/* Font sizes card */} +
+
+

Μεγέθη Γραμματοσειράς

+

+ Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση. +

+
+ {FONT_FIELDS.map(field => ( + + ))} +
+ + {/* Divider style card */} +
+
+

Διαχωριστικές Γραμμές

+
+ +
+ +
+ Σημείωση: Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48. + Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες). +
+
+ ) +} diff --git a/waiter_pwa/dev-dist/sw.js b/waiter_pwa/dev-dist/sw.js index 24f463e..0aa8397 100644 --- a/waiter_pwa/dev-dist/sw.js +++ b/waiter_pwa/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.qb5i81hq8" + "revision": "0.8icf0qrbd5" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/waiter_pwa/src/components/OrderDrawer.jsx b/waiter_pwa/src/components/OrderDrawer.jsx index 210b07e..de796df 100644 --- a/waiter_pwa/src/components/OrderDrawer.jsx +++ b/waiter_pwa/src/components/OrderDrawer.jsx @@ -122,7 +122,7 @@ function Row({ selected, onClick, children, right, left, style = {} }) { // ── Shared: single quick option row ────────────────────────────────────────── -function QuickOptionRow({ opt, quickState, setQuickState }) { +function QuickOptionRow({ opt, quickState, setQuickState, compact }) { const qty = quickState[opt.id] || 0 const selected = qty > 0 const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 })) @@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
) : null} > -
{opt.name}
- {opt.price > 0 &&
+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}
} +
{opt.name}
+ {opt.price > 0 &&
+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}
} ) } @@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
{favorites.map((fav, fi) => { if (fav.type === 'quick') { - return + return } if (fav.type === 'ingredient') { return @@ -375,9 +375,11 @@ function QuickTab({ product, quickState, setQuickState }) {

Δεν υπάρχουν γρήγορες επιλογές.

) return ( -
+
{quickOptions.map(opt => ( - +
+ +
))}
) @@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) { {lines.map((l, i) => (
-
- {l.qty > 1 && {l.qty}×} - {l.label} -
- {l.detail &&
{l.detail}
} + {l.group === 'prefs' ? ( + <> +
{l.label}
+
{l.value}
+ + ) : l.group === 'removed' ? ( +
+ Χωρίς {l.label} +
+ ) : l.group === 'extras' ? ( +
+ {l.label} + {l.subName && · {l.subName}} + {l.qty > 1 && ×{l.qty}} +
+ ) : ( +
+ {l.qty > 1 && {l.qty}×} + {l.label} +
+ )}
{l.price !== 0 &&
{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €
}
@@ -600,9 +618,23 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0) - const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}` - if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null }) - else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null }) + + // Skip if this is entirely the default selection + const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null + const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id + const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0 + ? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0]) + : null + const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset + ? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]) + : null + const isFullyDefault = isDefaultChoice + && (!inlineSub || inlineSub.name === defaultInlineSub?.name) + && (!sharedSub || sharedSub.name === defaultSharedSub?.name) + if (isFullyDefault) { price += delta; return } + + const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}` + lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null }) price += delta }) @@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt if (!sel) return const sub = opt.sub_choices?.find(s => s.name === sel.subName) const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty - lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null }) + lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null }) price += linePrice }) ingredients.forEach(ing => { - if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null }) + if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null }) }) return { summaryLines: lines, totalPrice: price * qty } @@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt const prefChoices = preferenceSets.flatMap(ps => { const choice = prefs[ps.id] if (!choice) return [] - const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }] const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null + const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null + + // Don't emit entries that are entirely at their defaults — nothing changed + const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null + const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id + const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0 + ? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0]) + : null + const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset + ? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]) + : null + const isFullyDefault = isDefaultChoice + && (!inlineSub || inlineSub.name === defaultInlineSub?.name) + && (!sharedSub || sharedSub.name === defaultSharedSub?.name) + if (isFullyDefault) return [] + + const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }] if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 }) - if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) { - const sharedSub = sharedSubs[ps.id] ?? null - if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 }) - } + if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 }) return entries }) diff --git a/waiter_pwa/src/components/OrderSummary.jsx b/waiter_pwa/src/components/OrderSummary.jsx index 193a13d..e010880 100644 --- a/waiter_pwa/src/components/OrderSummary.jsx +++ b/waiter_pwa/src/components/OrderSummary.jsx @@ -1,26 +1,112 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' function fmtPrice(v) { return Number(v).toFixed(2) + ' €' } +// ── Icons ───────────────────────────────────────────────────────────────────── + +function SectionIcon({ type }) { + const icons = { + prefs: , + quick: , + extras: , + removed: , + note: , + } + return {icons[type] ?? null} +} + +// ── Parse selected_options into grouped sections (same logic as cart) ──────── + +function buildSections(item) { + const sections = [] + const opts = (() => { + try { return item.selected_options ? JSON.parse(item.selected_options) : [] } catch { return [] } + })() + const removed = (() => { + try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] } + })() + + // We don't have product metadata here, so we classify by heuristics: + // - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them + // - id == null → sub-choice (follows its parent) + // Strategy: walk through opts in order, attaching sub-choices to their parent, + // then classify parent items: items with a real id that appear multiple times → extra (stacked), + // but without product metadata we can't fully distinguish prefs from extras. + // We use a simple rule: if an option with id appears only once in the stream → treat as pref + // (since extras can be added multiple times). This matches how handleAdd() emits them. + + const prefGroups = [] // { setName: null (unknown), values: [...] } + const extraGroups = [] // { id, name, subName, qty } + const quickLines = [] // { name, _qty } + + // Count how many times each id appears (extras can be stacked → appear multiple times) + const idCount = {} + opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 }) + + // Single pass: consume each item and its optional following sub (id=null) + const consumedAsSubAtIndex = new Set() + let i = 0 + while (i < opts.length) { + const o = opts[i] + if (consumedAsSubAtIndex.has(i)) { i++; continue } + + if (o.id == null) { + // Standalone id=null → quick option + const existing = quickLines.find(x => x.name === o.name) + if (existing) existing._qty = (existing._qty || 1) + 1 + else quickLines.push({ name: o.name, _qty: 1 }) + i++ + continue + } + + // id != null — look ahead for immediate sub + let subName = null + if (i + 1 < opts.length && opts[i + 1].id == null) { + subName = opts[i + 1].name + consumedAsSubAtIndex.add(i + 1) + } + + if (idCount[o.id] > 1) { + // Extra — appears multiple times in the list + const existing = extraGroups.find(g => g.id === o.id && g.subName === subName) + if (existing) existing.qty++ + else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 }) + } else { + // Single occurrence → preference choice + const value = subName ? `${o.name} · ${subName}` : o.name + prefGroups.push({ setName: null, values: [value] }) + } + i++ + } + + if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups }) + if (quickLines.length > 0) sections.push({ type: 'quick', lines: quickLines }) + if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups }) + if (removed.length > 0) sections.push({ type: 'removed', lines: removed.map(n => ({ name: n })) }) + if (item.notes) sections.push({ type: 'note', lines: [{ name: item.notes }] }) + + return sections +} + +// ── ItemRow ─────────────────────────────────────────────────────────────────── + function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) { const isPaid = item.status === 'paid' const isCancelled = item.status === 'cancelled' - const isStacked = item.quantity > 1 - let opts = [] - try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {} - let removed = [] - try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {} + const sections = buildSections(item) + const hasDetails = sections.length > 0 + const [expanded, setExpanded] = useState(false) - // Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll) + // Long-press detection const pressTimer = useRef(null) const didLongPress = useRef(false) const touchStartPos = useRef({ x: 0, y: 0 }) function handleTouchStart(e) { - if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return + if (!selectable || isPaid || isCancelled || !onLongPress) return didLongPress.current = false touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY } pressTimer.current = setTimeout(() => { @@ -35,11 +121,9 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) if (dx > 8 || dy > 8) clearTimeout(pressTimer.current) } - function handleTouchEnd() { - clearTimeout(pressTimer.current) - } + function handleTouchEnd() { clearTimeout(pressTimer.current) } - function handleClick() { + function handleBodyClick() { if (didLongPress.current) { didLongPress.current = false; return } if (selectable && !isPaid && !isCancelled) onToggle(item.id) } @@ -47,31 +131,115 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) return (
-
+ {/* Main row — click to select */} +
+ {/* Selection checkbox */} {selectable && !isPaid && !isCancelled && ( - + {selected ? '☑' : '☐'} )} - {item.product?.name || `#${item.product_id}`} + + {/* Name + badges */} +
+
+ {item.product?.name || `#${item.product_id}`} + {isPaid && Paid} + {isCancelled && Cancelled} + {!isPaid && !isCancelled && !item.printed && ( + + )} +
+
+ + {/* Qty + price */} ×{item.quantity} {fmtPrice(item.unit_price * item.quantity)} - {isPaid && Paid} - {isCancelled && Cancelled} - {!isPaid && !isCancelled && !item.printed && ( - + + {/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */} + {hasDetails && ( + )}
- {opts.map((o, i) =>
+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}
)} - {removed.map((r, i) =>
- {r}
)} - {item.notes &&
📝 {item.notes}
} + + {/* Expanded details */} + {expanded && hasDetails && ( +
+ {sections.map((sec, si) => ( +
+
+
+ {sec.type === 'prefs' && sec.lines.map((line, li) => ( +
+ + + {line.setName && ( + {line.setName} + )} + {line.values.join(' · ')} + +
+ ))} + {sec.type === 'quick' && sec.lines.map((line, li) => ( +
+ + + {line.name} + {line._qty > 1 && ×{line._qty}} + +
+ ))} + {sec.type === 'extras' && sec.lines.map((line, li) => ( +
+ + + {line.name} + {line.subName && · {line.subName}} + {line.qty > 1 && ×{line.qty}} + +
+ ))} + {sec.type === 'removed' && sec.lines.map((line, li) => ( +
+ + Χωρίς {line.name} +
+ ))} + {sec.type === 'note' && sec.lines.map((line, li) => ( +
+ + {line.name} +
+ ))} +
+
+ ))} +
+ )}
) } diff --git a/waiter_pwa/src/pages/AddItemsPage.jsx b/waiter_pwa/src/pages/AddItemsPage.jsx index af6bf34..c4d4182 100644 --- a/waiter_pwa/src/pages/AddItemsPage.jsx +++ b/waiter_pwa/src/pages/AddItemsPage.jsx @@ -31,6 +31,20 @@ export default function AddItemsPage() { setCategories(catRes.data) setProducts(prodRes.data) setOrderId(statusRes.data.active_order_id) + + // Pre-populate cart from "order again" if present + const stored = sessionStorage.getItem('orderAgainItems') + if (stored) { + sessionStorage.removeItem('orderAgainItems') + try { + const items = JSON.parse(stored) + const initialCart = items.map(it => ({ + ...it, + _key: Date.now() + Math.random(), + })) + setCart(initialCart) + } catch {} + } } load() }, [tableId]) @@ -139,28 +153,65 @@ export default function AddItemsPage() { const sections = [] if (item.selected_options?.length) { - // Group consecutive options into logical sections by type - // Prefs: options that match a preference choice (have a real id matching preference_sets choices) const prefIds = new Set( (product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id)) ) + // Build a map: prefChoiceId → preference set name + const prefSetByChoiceId = {} + ;(product?.preference_sets || []).forEach(ps => { + ps.choices.forEach(c => { prefSetByChoiceId[c.id] = ps.name }) + }) const quickNames = new Set((product?.quick_options || []).map(o => o.name)) const extraIds = new Set((product?.options || []).map(o => o.id)) - const prefLines = [] + // Group prefs: { prefSetName, choiceName, subName } + const prefGroups = [] + // Group extras: { name, subName, qty } — one entry per unique (id) + const extraGroups = [] const quickLines = [] - const extraLines = [] - item.selected_options.forEach(o => { - if (prefIds.has(o.id)) prefLines.push(o) - else if (o.id != null && extraIds.has(o.id)) extraLines.push(o) - else if (quickNames.has(o.name)) quickLines.push(o) - else if (o.id == null) { - // sub-choice — attach to last extra or pref line - if (extraLines.length > 0) extraLines.push({ ...o, _sub: true }) - else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true }) + let i = 0 + const opts = item.selected_options + while (i < opts.length) { + const o = opts[i] + if (prefIds.has(o.id)) { + // Collect sub immediately following (id === null) + const setName = prefSetByChoiceId[o.id] ?? '' + let subName = null + if (i + 1 < opts.length && opts[i + 1].id == null) { + subName = opts[i + 1].name + i += 2 + } else { + i++ + } + // Merge into existing prefGroup for same setName, or create new + const existing = prefGroups.find(g => g.setName === setName) + if (existing) { + // multiple choices from same set (shouldn't normally happen, but handle gracefully) + existing.values.push(subName ? `${o.name} · ${subName}` : o.name) + } else { + prefGroups.push({ setName, values: [subName ? `${o.name} · ${subName}` : o.name] }) + } + } else if (o.id != null && extraIds.has(o.id)) { + // Collect sub immediately following + let subName = null + if (i + 1 < opts.length && opts[i + 1].id == null) { + subName = opts[i + 1].name + i += 2 + } else { + i++ + } + // Merge duplicates + const existing = extraGroups.find(g => g.id === o.id && g.subName === subName) + if (existing) existing.qty++ + else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 }) + } else if (quickNames.has(o.name)) { + quickLines.push(o) + i++ + } else { + i++ } - }) + } // Deduplicate quick lines: multiple entries of same name → single entry with qty const quickDeduped = [] @@ -170,9 +221,9 @@ export default function AddItemsPage() { else quickDeduped.push({ ...o, _qty: 1 }) }) - if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines }) + if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups }) if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped }) - if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines }) + if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups }) } if (item.removed_ingredients?.length) { @@ -196,7 +247,7 @@ export default function AddItemsPage() { else lines.push(o.name) }) } - if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`) + if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`) if (item.notes) lines.push(item.notes) return lines } @@ -502,29 +553,48 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
{sections.map((sec, si) => (
- {/* Divider between sections */}
- {sec.lines.map((line, li) => ( + {sec.type === 'prefs' && sec.lines.map((line, li) => (
- - - {sec.type === 'note' ? line.name : ( - <> - {line.name} - {line._qty > 1 && ( - ×{line._qty} - )} - {line.price_delta !== 0 && line.price_delta != null && ( - - ({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} €) - - )} - - )} + + + {line.setName} + {line.values.join(' · ')}
))} + {sec.type === 'quick' && sec.lines.map((line, li) => ( +
+ + + {line.name} + {line._qty > 1 && ×{line._qty}} + +
+ ))} + {sec.type === 'extras' && sec.lines.map((line, li) => ( +
+ + + {line.name} + {line.subName && · {line.subName}} + {line.qty > 1 && ×{line.qty}} + +
+ ))} + {sec.type === 'removed' && sec.lines.map((line, li) => ( +
+ + Χωρίς {line.name} +
+ ))} + {sec.type === 'note' && sec.lines.map((line, li) => ( +
+ + {line.name} +
+ ))}
))} diff --git a/waiter_pwa/src/pages/TableDetailPage.jsx b/waiter_pwa/src/pages/TableDetailPage.jsx index 055d4c3..3b23ee8 100644 --- a/waiter_pwa/src/pages/TableDetailPage.jsx +++ b/waiter_pwa/src/pages/TableDetailPage.jsx @@ -81,6 +81,80 @@ function SplitModal({ item, onConfirm, onClose }) { ) } +// ─── Item action modal (long-press) ────────────────────────────────────────── + +function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) { + const { items, singleStacked, multiSelect } = target + const label = multiSelect + ? `${items.length} αντικείμενα επιλεγμένα` + : items[0]?.product?.name || `#${items[0]?.product_id}` + + return ( +
+
e.stopPropagation()} style={{ gap: 0 }}> +
+

{label}

+ + + + {singleStacked && !multiSelect && ( + + )} + + +
+
+ ) +} + // ─── Actions top sheet ──────────────────────────────────────────────────────── function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) { @@ -429,6 +503,7 @@ export default function TableDetailPage() { const [allWaiters, setAllWaiters] = useState([]) const [actionDataLoading, setActionDataLoading] = useState(false) const [splitItem, setSplitItem] = useState(null) + const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool } const scrollRef = useRef(null) @@ -800,7 +875,15 @@ export default function TableDetailPage() { selectable={canInteract && !paying} selectedIds={selectedIds} onToggle={toggleItem} - onLongPressItem={(item) => { setSplitItem(item) }} + onLongPressItem={(item) => { + // If multiple items are selected, order-again all selected items + if (selectedIds.length > 1) { + const items = activeItems.filter(i => selectedIds.includes(i.id)) + setItemActionTarget({ items, singleStacked: false, multiSelect: true }) + } else { + setItemActionTarget({ items: [item], singleStacked: item.quantity > 1, multiSelect: false }) + } + }} /> {/* Floating controls row — only visible when items are selected */} @@ -937,6 +1020,32 @@ export default function TableDetailPage() {
)} + {/* Item action modal (long-press) */} + {itemActionTarget && ( + { + const items = itemActionTarget.items + sessionStorage.setItem('orderAgainItems', JSON.stringify( + items.map(it => ({ + product_id: it.product_id, + quantity: it.quantity, + selected_options: (() => { try { return JSON.parse(it.selected_options || '[]') } catch { return [] } })(), + removed_ingredients: (() => { try { return JSON.parse(it.removed_ingredients || '[]') } catch { return [] } })(), + notes: it.notes || '', + })) + )) + setItemActionTarget(null) + navigate(`/tables/${tableId}/add`) + }} + onSplit={() => { + setSplitItem(itemActionTarget.items[0]) + setItemActionTarget(null) + }} + onClose={() => setItemActionTarget(null)} + /> + )} + {/* Split stepper modal */} {splitItem && (