general fixes and ordering display overhaul

This commit is contained in:
2026-04-30 16:58:13 +03:00
parent 1fd7d16ec9
commit 8e27b7666e
19 changed files with 1470 additions and 335 deletions

View File

@@ -0,0 +1,85 @@
# Printer Beep Strategy
## How beeping works on the Jolimark TP850UE
The printer has a built-in buzzer. Three commands are available:
| Command | Bytes | Parameters | Notes |
|---------|-------|------------|-------|
| `BEL` | `0x07` | none | Single short beep, ~50ms. Simplest. |
| `ESC BEL n1 n2 n3` | `0x1B 0x07 n1 n2 n3` | n1=on-time (×100ms), n2=off-time (×100ms), n3=count | Full control over length, gap, repetitions. |
| `GS BEL n1 n2 n3` | `0x1D 0x07 n1 n2 n3` | n1=count, n2=on-time (×100ms), n3=off-time (×100ms) | Same as ESC BEL but parameter order differs. |
**Confirmed working on our test:** `ESC BEL` with `n1=2, n2=2, n3=1` = one 200ms beep.
Pattern beeps also work: `ESC BEL 1 1 3` = three short beeps in quick succession.
The beep is triggered by sending these bytes **immediately before or after** the print job —
it does not need to be part of a complete print page. You can send a beep-only job
(connect, send beep bytes, close) without printing anything.
---
## Where to add beeps in the system
### 1. New kitchen ticket arrives (MOST IMPORTANT)
**Where:** `printer_service.py``_print_kitchen_ticket()`, just before or after the cut command.
**Pattern:** 2 short beeps — signals a new order without being annoying.
```python
p._raw(bytes([0x1b, 0x07, 1, 1, 2])) # 2× 100ms beeps
p.cut()
```
### 2. Re-print of an existing ticket
**Where:** Same function, but only 1 beep to distinguish from a new order.
```python
p._raw(bytes([0x1b, 0x07, 1, 2, 1])) # 1× 100ms beep
```
### 3. Urgent / rush order (future feature)
**Where:** If we add an "urgent" flag to orders, trigger a longer or triple beep.
```python
p._raw(bytes([0x1b, 0x07, 3, 1, 3])) # 3× 300ms beeps
```
### 4. Test print
**Where:** `send_test_print()` — already sends a test page, add 1 beep so the cook knows
to look at the printer.
---
## Implementation plan (when ready)
1. **Add a per-printer setting:** `print.beep_on_ticket` = `true`/`false`
(some stations may not want beeping, e.g. a bar printer near customers)
2. **Add a beep pattern setting:** `print.beep_pattern` = `single` / `double` / `triple`
3. **In `_print_kitchen_ticket`:** After building the ticket, before `p.cut()`:
```python
if beep_enabled:
p._raw(beep_bytes_for_pattern)
```
4. **No separate beep job needed** — bake it into the ticket job. The buzzer fires
as the paper is cutting, which is the natural attention signal.
---
## Settings keys to add (future)
```
print.beep_on_ticket "true" / "false" default: "true"
print.beep_pattern "single" / "double" / "triple" default: "double"
```
These can go in the Printer management UI (per-printer toggle) and in the
Print Settings tab (global default pattern).
---
## Notes
- Beep bytes are sent over the same TCP socket as print data — no separate connection needed.
- The buzzer is hardware-limited; very short intervals (< 50ms) may be ignored.
- Beeping does NOT require paper to be loaded or printing to succeed — it fires independently.
- If spoof-printing mode is ON, the beep should also be suppressed (no real connection is made).

View File

@@ -177,6 +177,10 @@ def _run_migrations():
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0", "ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
# Auto-expand flag for sub-categories on the PWA accordion # Auto-expand flag for sub-categories on the PWA accordion
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0", "ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
# Printer protocol field
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
# Compact (half-width) display flag for quick options
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
] ]
for sql in migrations: for sql in migrations:
try: try:

View File

@@ -11,6 +11,7 @@ class Printer(Base):
ip_address = Column(String, nullable=False) ip_address = Column(String, nullable=False)
port = Column(Integer, default=9100, nullable=False) port = Column(Integer, default=9100, nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
protocol = Column(String, default="escpos_tcp", nullable=False)
products = relationship("Product", back_populates="printer_zone") products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer") print_logs = relationship("PrintLog", back_populates="printer")

View File

@@ -72,6 +72,7 @@ class ProductQuickOption(Base):
sort_order = Column(Integer, default=0, nullable=False) sort_order = Column(Integer, default=0, nullable=False)
is_favorite = Column(Boolean, default=False, nullable=False) is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False) favorite_sort_order = Column(Integer, default=0, nullable=False)
is_compact = Column(Boolean, default=False, nullable=False)
product = relationship("Product", back_populates="quick_options") product = relationship("Product", back_populates="quick_options")

View File

@@ -1,305 +1,343 @@
""" """
Printer font & symbol test script. Printer comprehensive test script — Jolimark TP850UE
Usage (inside Docker): python print_test.py [IP] [PORT] Usage: python print_test.py [IP] [PORT]
Defaults to 10.98.20.25:9100 Default: 10.98.20.25:9100
Prints 6 pages:
Page 1 — ESC ! modes, Font A, English
Page 2 — ESC ! modes, Font B, English
Page 3 — ESC ! modes, Font A, Greek
Page 4 — ESC ! modes, Font B, Greek
Page 5 — GS ! character size multipliers (both fonts)
Page 6 — Beep tests + misc (underline, invert, symbols)
ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
Bit 0 (0x01) — Font B instead of Font A
Bit 3 (0x08) — Emphasize / Bold
Bit 4 (0x10) — Double-height
Bit 5 (0x20) — Double-width
Bit 7 (0x80) — Underline
GS ! (0x1D 0x21 n) character size multiplier:
Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
""" """
import sys import sys
import time
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25" PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100 PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
from escpos.printer import Network from escpos.printer import Network
# ── Low-level helpers ──────────────────────────────────────────────────────────
def _gr(text: str) -> bytes: def _gr(text: str) -> bytes:
return text.encode('cp737', errors='replace') return text.encode('cp737', errors='replace')
def _open(): def _open():
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10) p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
p._raw(b'\x1b\x40') # ESC @ reset p._raw(b'\x1b\x40') # ESC @ — full reset
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
return p return p
def _t(p, text: str): def _t(p, text: str):
p._raw(_gr(text)) p._raw(_gr(text))
def _reset(p):
"""Reset to: Font A, normal size, no bold, left-align."""
p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
def _center(p): p._raw(b'\x1b\x61\x01')
def _left(p): p._raw(b'\x1b\x61\x00')
def _divider(p, char="-", width=48): def _divider(p, char="-", width=48):
p._raw(b'\x1b\x61\x00') _left(p)
_t(p, char * width + "\n") _t(p, char * width + "\n")
def _center(p): def _page_header(p, title: str):
p._raw(b'\x1b\x61\x01') _center(p)
p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
_t(p, title + "\n")
_reset(p)
_divider(p, "=")
def _left(p):
p._raw(b'\x1b\x61\x00')
# ── ESC ! n byte reference ────────────────────────────────────────────────── # ── ESC ! mode table ───────────────────────────────────────────────────────────
# Bit 0 → underline (not tested, minor) #
# Bit 1 → double-strike (bold) # Each entry: (esc_bang_byte, esc_e_bold, label)
# Bit 3 → double-height # esc_bang_byte sets the mode via ESC ! n
# Bit 4 → double-width # esc_e_bold adds ESC E on top (independent bold layer)
# Bit 5 → delete-line # We test every useful combination so you can see the exact visual result.
# Bit 7 → bold (ESC E alias in some models)
# Common combos used here:
# 0x00 = normal
# 0x08 = double-height only (48 chars wide)
# 0x10 = double-height only (alt bit) (48 chars wide)
# 0x18 = double-height + bold
# 0x20 = double-width only (24 chars wide)
# 0x30 = double-width + double-height (24 chars wide)
# 0x38 = double-width + double-height + bold
# 0x48 = double-height (bit 6 combo — some printers)
MODES = [ ESC_BANG_MODES = [
(0x00, "Normal (0x00)"), # (byte, extra_bold, label)
(0x08, "Double-height bit3 (0x08)"), (0x00, False, "0x00 Normal"),
(0x10, "Double-height bit4 (0x10)"), (0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
(0x18, "Double-height + Bold (0x18)"), (0x08, False, "0x08 Bold only (bit3)"),
(0x20, "Double-width (0x20)"), (0x10, False, "0x10 Double-height (bit4)"),
(0x30, "Double-width + Double-height (0x30)"), (0x10, True, "0x10 +ESC E Double-height + Bold"),
(0x38, "Double-width + Double-height + Bold (0x38)"), (0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
(0x20, False, "0x20 Double-width (bit5)"),
(0x20, True, "0x20 +ESC E Double-width + Bold"),
(0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
(0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
(0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
] ]
# ── Section 1 — Font sizes & styles, English ───────────────────────────────
def section_english(p): def _esc_bang_section(p, english: bool):
_center(p) lang = "EN" if english else "GR"
p._raw(b'\x1b\x21\x38') sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
_t(p, "=== FONT SIZES (EN) ===\n") sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
for code, label in MODES: for (byte_val, extra_bold, label) in ESC_BANG_MODES:
_left(p) _left(p)
_t(p, f"[{label}]\n") # Print the label in small normal text first
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "TEST PRINT Hello World Abc123\n")
p._raw(b'\x1b\x21\x00') p._raw(b'\x1b\x21\x00')
# bold on/off via ESC E
_t(p, " -> bold ON: ")
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold Sample\n")
p._raw(b'\x1b\x45\x00') p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
def section_greek(p):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (GR) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
for code, label in MODES:
_left(p)
_t(p, f"[{label}]\n") _t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n") # Apply mode
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase p._raw(bytes([0x1b, 0x21, byte_val]))
p._raw(b'\x1b\x21\x00') if extra_bold:
p._raw(b'\x1b\x45\x01') p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold: Καλημερα Κοσμε\n") _t(p, sample_normal + "\n")
p._raw(b'\x1b\x45\x00') _t(p, sample_lower + "\n")
p._raw(b'\x1b\x21\x00')
# Reset
_reset(p)
_t(p, "\n") _t(p, "\n")
_divider(p) _divider(p)
p._raw(b'\n')
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
def section_ascii_symbols(p): # ── Pages 14: ESC ! modes ────────────────────────────────────────────────────
_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 def page_esc_bang(font_b: bool, english: bool):
chars = [chr(c) for c in range(0x20, 0x7F)] font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
line = "" lang_label = "GREEK" if not english else "ENGLISH"
for i, ch in enumerate(chars): p = _open()
line += ch + " "
if (i + 1) % 24 == 0:
_t(p, line + "\n")
line = ""
if line:
_t(p, line + "\n")
_t(p, "\n") # Select font
_t(p, "Notable:\n") p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n") _page_header(p, f"ESC! MODES — {lang_label} {font_label[:6]}")
_t(p, " Currency : $ %\n") _t(p, f"Font: {font_label}\n")
_divider(p) _divider(p)
p._raw(b'\n')
# ── Section 4 — CP737 extended chars (0x800xFF) ─────────────────────────── _esc_bang_section(p, english)
def section_extended(p): p._raw(b'\n\n\n')
_center(p) p.cut()
p._raw(b'\x1b\x21\x18') p.close()
_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") # ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
_t(p, "Key CP737 specials:\n")
specials = [ # Combinations worth seeing: square multipliers + some asymmetric
(0xB3, "─ thin horiz line"), GS_SIZES = [
(0xC4, "─ double line"), (0x00, "1x1 normal"),
(0xBA, "│ vert line"), (0x01, "1w x 2h"),
(0xBB, "┐ corner"), (0x10, "2w x 1h"),
(0xBC, "┘ corner"), (0x11, "2x2"),
(0xC9, "╔ corner"), (0x22, "3x3"),
(0xCA, "╩ junction"), (0x33, "4x4"),
(0xCB, "╦ junction"), (0x44, "5x5"),
(0xCC, "╠ junction"), (0x55, "6x6"),
(0xCD, "═ double horiz"), (0x02, "1w x 3h"),
(0xCE, "╬ cross"), (0x20, "3w x 1h"),
(0xC8, "╚ corner"), (0x21, "3w x 2h"),
(0xBB, "╗ corner"), (0x12, "2w x 3h"),
(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 def page_gs_sizes():
p._raw(row_bytes) p = _open()
_page_header(p, "GS! SIZE MULTIPLIERS")
_t(p, "GS ! n (0x1D 0x21 n)\n")
_t(p, "Low nibble=height, High nibble=width\n")
_divider(p)
for (byte_val, label) in GS_SIZES:
_left(p)
# Label in tiny normal text
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1d\x21\x00')
_t(p, f"[n=0x{byte_val:02X} {label}]\n")
# Font A sample
p._raw(b'\x1b\x4d\x00')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "Aa SAMPLE\n")
p._raw(b'\x1d\x21\x00')
# Font B sample on same size
p._raw(b'\x1b\x4d\x01')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "Bb SMALL\n")
p._raw(b'\x1d\x21\x00')
p._raw(b'\x1b\x4d\x00') # back to Font A
_t(p, "\n")
_divider(p)
# Also show GS ! combined with ESC ! bold
_t(p, "\n")
_divider(p, "=")
p._raw(b'\x1b\x21\x00')
_t(p, "GS! + ESC E bold combined:\n")
_divider(p, "=")
for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1d\x21\x00')
_t(p, f"[{label} + bold]\n")
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "BOLD LARGE\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1d\x21\x00')
_t(p, "\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
# ── Page 6: Beep + misc ────────────────────────────────────────────────────────
def page_beep_misc():
p = _open()
_page_header(p, "BEEP + MISC TESTS")
# ── Beep section ──
_t(p, "BEEP TESTS\n")
_divider(p, "-")
_t(p, "Sending beeps now...\n\n")
# BEL — single beep (0x07)
_t(p, "[1] BEL single beep (0x07)\n")
p._raw(b'\x07')
time.sleep(0.5)
# ESC BEL n1 n2 n3 — beep for appointment
# n1=beep length (100ms units), n2=intermission (100ms), n3=count
_t(p, "[2] ESC BEL: 1 beep, 200ms long\n")
p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep
time.sleep(0.8)
_t(p, "[3] ESC BEL: 3 short beeps\n")
p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps
time.sleep(1.5)
_t(p, "[4] ESC BEL: 1 long beep (500ms)\n")
p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep
time.sleep(1.2)
_t(p, "[5] GS BEL: 2 beeps\n")
p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off
time.sleep(1.5)
_t(p, "Beep tests done.\n")
_divider(p)
# ── Underline ──
_t(p, "\nUNDERLINE\n")
_divider(p, "-")
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: Hello World 123\n")
p._raw(b'\x1b\x2d\x00')
_t(p, "\n")
_divider(p)
# ── White-on-black invert ──
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
_divider(p, "-")
p._raw(b'\x1d\x42\x01')
_t(p, " INVERTED NORMAL \n")
p._raw(b'\x1d\x21\x11') # 2x2 inverted
_t(p, " INVERTED 2x2 \n")
p._raw(b'\x1d\x21\x00')
p._raw(b'\x1d\x42\x00')
_t(p, "Normal after invert\n")
_divider(p)
# ── 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)
# ── 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") _t(p, f" {desc}\n")
_divider(p) _divider(p)
p._raw(b'\n')
# ── Section 5 — Underline ────────────────────────────────────────────────── p._raw(b'\n\n\n')
p.cut()
p.close()
def section_underline(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== UNDERLINE TEST ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# ESC - n : underline 0=off, 1=thin, 2=thick # ── Main ───────────────────────────────────────────────────────────────────────
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
p._raw(b'\x1b\x2d\x00') # off
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
def section_invert(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== INVERT (white-on-black) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# GS B n — invert
p._raw(b'\x1d\x42\x01')
_t(p, " INVERTED TEXT SAMPLE \n")
p._raw(b'\x1d\x42\x00')
_t(p, "Normal text after invert\n")
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 7 — QR Code sample ────────────────────────────────────────────
def section_qr(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== QR CODE SAMPLE ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
data = b"https://pos.test"
# GS ( k — QR store data
store_len = len(data) + 3
p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data)
# GS ( k — set size (module=6)
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06')
# GS ( k — error correction level M
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31')
# GS ( k — print
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30')
_t(p, "\nhttps://pos.test\n\n")
_divider(p)
p._raw(b'\n')
# ── Main ───────────────────────────────────────────────────────────────────
def main(): def main():
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...") print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
print("Printing 6 pages...\n")
# ---- PAGE 1: English fonts ---- page_esc_bang(font_b=False, english=True)
p = _open() print("Page 1 done — ESC! modes, Font A, English")
section_english(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 1 sent (English fonts)")
# ---- PAGE 2: Greek fonts ---- page_esc_bang(font_b=True, english=True)
p = _open() print("Page 2 done — ESC! modes, Font B, English")
section_greek(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 2 sent (Greek fonts)")
# ---- PAGE 3: Symbols & special chars ---- page_esc_bang(font_b=False, english=False)
p = _open() print("Page 3 done — ESC! modes, Font A, Greek")
section_ascii_symbols(p)
section_extended(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 3 sent (ASCII + CP737 extended)")
# ---- PAGE 4: Underline + Invert + QR ---- page_esc_bang(font_b=True, english=False)
p = _open() print("Page 4 done — ESC! modes, Font B, Greek")
section_underline(p)
section_invert(p)
section_qr(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 4 sent (underline / invert / QR)")
print("Done — 4 pages printed.") page_gs_sizes()
print("Page 5 done — GS! size multipliers")
page_beep_misc()
print("Page 6 done — Beep tests + misc")
print("\nAll done.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -35,6 +35,7 @@ def _replace_quick_options(db, product, quick_options):
sort_order=qo.sort_order if qo.sort_order else i, sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite, is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order, favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
)) ))
@@ -206,6 +207,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
sort_order=qo.sort_order if qo.sort_order else i, sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite, is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order, favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
)) ))
for opt in body.options: for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None

View File

@@ -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.", "system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.", "ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.", "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 = { DEFAULTS = {
@@ -26,6 +33,12 @@ DEFAULTS = {
"system.timezone": "Europe/Athens", "system.timezone": "Europe/Athens",
"ui.table_colours": "", "ui.table_colours": "",
"dev.spoof_printing": "false", "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",
} }

View File

@@ -5,7 +5,7 @@ from typing import List
from database import get_db from database import get_db
from models.printer import Printer from models.printer import Printer
from schemas.printer import PrinterUpdate, PrinterOut from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin from routers.deps import get_current_user, require_manager, require_sysadmin
from models.user import User from models.user import User
from services import printer_service from services import printer_service
@@ -40,7 +40,16 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
@router.get("/printers", response_model=List[PrinterOut]) @router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)): def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).filter(Printer.is_active == True).all() return db.query(Printer).all()
@router.post("/printers", response_model=PrinterOut)
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = Printer(**body.model_dump())
db.add(printer)
db.commit()
db.refresh(printer)
return printer
@router.post("/printers/test") @router.post("/printers/test")
@@ -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) @router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)): def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer: if not printer:
raise HTTPException(status_code=404, detail="Printer not found") raise HTTPException(status_code=404, detail="Printer not found")
@@ -64,6 +73,16 @@ def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(g
return printer return printer
@router.delete("/printers/{printer_id}")
def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
db.delete(printer)
db.commit()
return {"ok": True}
@router.post("/lock") @router.post("/lock")
def lock_system(token: str, user: User = Depends(require_sysadmin)): def lock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = True license_state["locked"] = True

View File

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

View File

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

View File

@@ -47,9 +47,57 @@ def _raw_text(p: Network, text: str):
p._raw(_gr(text)) p._raw(_gr(text))
def _divider(p: Network): _DIVIDER_CHARS = {
"dash": "-",
"equals": "=",
"star": "*",
"empty": "",
}
_PRINT_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(b'\x1b\x61\x00')
p._raw(_gr("-" * LINE_WIDTH + "\n")) if char:
p._raw(_gr(char * LINE_WIDTH + "\n"))
else:
p._raw(b'\n')
def _item_line(name: str, qty: int) -> str: def _item_line(name: str, qty: int) -> str:
@@ -106,34 +154,50 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
# ── Receipt formatting ─────────────────────────────────────────────────────── # ── 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): 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\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") _raw_text(p, f"Παραγγελια #{order.id}\n")
p._raw(b'\x1b\x21\x00') 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\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") now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
_raw_text(p, f"Date: {now}\n") _raw_text(p, f"Date: {now}\n")
_raw_text(p, f"Table: {order.table_id}\n") _raw_text(p, f"Table: {order.table_id}\n")
_raw_text(p, f"Waiter: {order.opened_by}\n") _raw_text(p, f"Waiter: {order.opened_by}\n")
p._raw(b'\x1b\x21\x00') p._raw(b'\x1b\x21\x00')
_divider(p) p._raw(b'\x1b\x45\x00')
_divider(p, div)
# Items # Items
for item in items: for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first() product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}" name = product.name if product else f"Product #{item.product_id}"
p._raw(b'\x1b\x21\x10') _font(p, sz_item, bold_item)
p._raw(b'\x1b\x45\x01') # bold on
_raw_text(p, _item_line(name, item.quantity) + "\n") _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: if item.removed_ingredients:
try: try:
removed_ids = json.loads(item.removed_ingredients) 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") _raw_text(p, f" (i) {item.notes}\n")
p._raw(b'\x1b\x21\x00') p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_divider(p) _divider(p, div)
if order.notes: if order.notes:
p._raw(b'\x1b\x21\x30') p._raw(b'\x1b\x21\x30')

View File

@@ -766,6 +766,7 @@ function buildFormFromProduct(product) {
sort_order: q.sort_order ?? 0, sort_order: q.sort_order ?? 0,
is_favorite: q.is_favorite ?? false, is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0, favorite_sort_order: q.favorite_sort_order ?? 0,
is_compact: q.is_compact ?? false,
})) ?? [], })) ?? [],
options: product.options?.map(o => ({ options: product.options?.map(o => ({
name: o.name, name: o.name,
@@ -906,7 +907,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
} }
// ── Quick Options ── // ── Quick Options ──
function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) } function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) } function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) } function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) } function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
@@ -1083,6 +1084,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
sort_order: i, sort_order: i,
is_favorite: q.is_favorite ?? false, is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0, favorite_sort_order: q.favorite_sort_order ?? 0,
is_compact: q.is_compact ?? false,
})), })),
options: form.options.map(o => ({ options: form.options.map(o => ({
name: o.name, name: o.name,
@@ -1346,6 +1348,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
className="accent-primary-700 w-4 h-4" /> className="accent-primary-700 w-4 h-4" />
Πολλαπλά Πολλαπλά
</label> </label>
<label title="Μισό πλάτος στο PWA" className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: q.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={q.is_compact ?? false}
onChange={e => setQuickOption(i, 'is_compact', e.target.checked)}
className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button> <button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div> </div>
))} ))}

View File

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

View File

@@ -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 (
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
<span style={{
color: '#f5f5f5',
fontFamily: 'Arial, Helvetica, sans-serif',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
SAMPLE
</span>
</div>
)
}
// ── 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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 20px', borderBottom: '1px solid #f4f4f2',
}}>
{/* Label */}
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
{field.label}
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
</div>
{/* Size dropdown */}
<select
value={size}
onChange={handleSize}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{FONT_SIZE_OPTIONS.map(o => (
<option key={o.size} value={o.size}>{o.label}</option>
))}
</select>
{/* Bold toggle */}
<button
onClick={handleBold}
disabled={isPending}
style={{
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
border: `1.5px solid ${bold ? '#3758c9' : '#dfe2e6'}`,
background: bold ? '#eff3ff' : 'white',
color: bold ? '#3758c9' : '#6b7280',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: `2px solid ${bold ? '#3758c9' : '#9ca3af'}`,
background: bold ? '#3758c9' : 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{bold && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}></span>}
</span>
ΕΝΤΟΝΑ
</button>
{/* Preview */}
<FontPreview size={size} bold={bold} />
</div>
)
}
// ── Divider row ────────────────────────────────────────────────────────────
function DividerRow({ value, onChange, isPending }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 20px',
}}>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Διαχωριστικού
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
</div>
<select
value={value}
onChange={e => onChange('print.divider_style', e.target.value)}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{DIVIDER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* spacer to align with bold button column */}
<div style={{ width: 87, flexShrink: 0 }} />
{/* Preview — same fixed size as font previews */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{value === 'empty'
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
</span>
}
</div>
</div>
)
}
// ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
return (
<div style={{
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Κουζίνα" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
placeholder="10.98.20.25" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
type="number" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
</div>
</div>
)
}
const inputStyle = {
height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white',
padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%',
}
const btnPrimary = {
height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white',
border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}
const btnSecondary = {
height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151',
}
const btnDanger = {
height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2',
background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626',
}
function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) {
const [reachable, setReachable] = useState(null)
useEffect(() => {
let cancelled = false
client.get('/api/system/status').then(r => {
if (cancelled) return
const match = r.data.printers?.find(p => p.id === printer.id)
if (match) setReachable(match.reachable)
}).catch(() => {})
return () => { cancelled = true }
}, [printer.id])
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
opacity: printer.is_active ? 1 : 0.5,
flexWrap: 'wrap',
}}>
{/* Enable/disable toggle */}
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
style={{
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
}}>
<span style={{
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
width: 16, height: 16, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
{/* Name + connection info */}
<div style={{ flex: 1, minWidth: 120 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
</div>
{/* Reachability badge */}
<span style={{
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
}}>
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
</span>
{/* Actions */}
<button onClick={() => onTest(printer.id)} disabled={testPending}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Test Print
</button>
<button onClick={() => onEdit(printer)}
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
Επεξεργασία
</button>
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
Διαγραφή
</button>
</div>
)
}
function PrintersSection() {
const qc = useQueryClient()
const [showNew, setShowNew] = useState(false)
const [editingId, setEditingId] = useState(null)
const { data: printers = [], isLoading } = useQuery({
queryKey: ['printers-all'],
queryFn: () => client.get('/api/system/printers').then(r => r.data),
staleTime: 15_000,
})
const createMut = useMutation({
mutationFn: body => client.post('/api/system/printers', body),
onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) },
onError: () => toast.error('Σφάλμα δημιουργίας'),
})
const updateMut = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteMut = useMutation({
mutationFn: id => client.delete(`/api/system/printers/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) },
onError: () => toast.error('Σφάλμα διαγραφής'),
})
const testMut = useMutation({
mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`),
onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`),
onError: () => toast.error('Σφάλμα επικοινωνίας'),
})
function handleToggle(printer) {
updateMut.mutate({ id: printer.id, is_active: !printer.is_active })
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
<div>
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
<p className="text-xs text-gray-400 mt-0.5">Διαχείριση εκτυπωτών του συστήματος</p>
</div>
<button onClick={() => { setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
+ Νέος εκτυπωτής
</button>
</div>
{showNew && (
<div style={{ padding: '12px 20px' }}>
<PrinterForm
onSave={form => createMut.mutate(form)}
onCancel={() => setShowNew(false)}
isPending={createMut.isPending}
/>
</div>
)}
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</p>}
{!isLoading && printers.length === 0 && !showNew && (
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
Δεν υπάρχουν εκτυπωτές ακόμα.
</p>
)}
{printers.map(printer => (
editingId === printer.id ? (
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
<PrinterForm
initial={printer}
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
onCancel={() => setEditingId(null)}
isPending={updateMut.isPending}
/>
</div>
) : (
<PrinterRow
key={printer.id}
printer={printer}
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
onDelete={id => deleteMut.mutate(id)}
onTest={id => testMut.mutate(id)}
onToggle={handleToggle}
testPending={testMut.isPending}
/>
)
))}
</div>
)
}
// ── 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 <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<PrintersSection />
{/* Font sizes card */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
<p className="text-xs text-gray-400 mt-0.5">
Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση.
</p>
</div>
{FONT_FIELDS.map(field => (
<FontRow
key={field.key}
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
/>
))}
</div>
{/* Divider style card */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
</div>
<DividerRow
value={val('print.divider_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
</div>
<div style={{
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
}}>
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
</div>
</div>
)
}

View File

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

View File

@@ -122,7 +122,7 @@ function Row({ selected, onClick, children, right, left, style = {} }) {
// ── Shared: single quick option row ────────────────────────────────────────── // ── Shared: single quick option row ──────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) { function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
const qty = quickState[opt.id] || 0 const qty = quickState[opt.id] || 0
const selected = qty > 0 const selected = qty > 0
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 })) const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
@@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
</div> </div>
) : null} ) : null}
> >
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div> <div style={{ fontSize: compact ? 13 : 15, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>} {opt.price > 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>}
</Row> </Row>
) )
} }
@@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{favorites.map((fav, fi) => { {favorites.map((fav, fi) => {
if (fav.type === 'quick') { if (fav.type === 'quick') {
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} /> return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} compact={false} />
} }
if (fav.type === 'ingredient') { if (fav.type === 'ingredient') {
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} /> return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
@@ -375,9 +375,11 @@ function QuickTab({ product, quickState, setQuickState }) {
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p> <p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
) )
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{quickOptions.map(opt => ( {quickOptions.map(opt => (
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} /> <div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} compact={opt.is_compact} />
</div>
))} ))}
</div> </div>
) )
@@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) {
{lines.map((l, i) => ( {lines.map((l, i) => (
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}> <div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
{l.group === 'prefs' ? (
<>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</div>
</>
) : l.group === 'removed' ? (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Χωρίς {l.label}
</div>
) : l.group === 'extras' ? (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{l.label}
{l.subName && <span> · {l.subName}</span>}
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginLeft: 6, fontVariantNumeric: 'tabular-nums' }}>×{l.qty}</span>}
</div>
) : (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}> <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>} {l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
{l.label} {l.label}
</div> </div>
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>} )}
</div> </div>
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} </div>} {l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} </div>}
</div> </div>
@@ -600,9 +618,23 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0) const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null }) // Skip if this is entirely the default selection
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null }) const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
: null
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
: null
const isFullyDefault = isDefaultChoice
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) { price += delta; return }
const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null })
price += delta price += delta
}) })
@@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
if (!sel) return if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName) const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null }) lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null })
price += linePrice price += linePrice
}) })
ingredients.forEach(ing => { ingredients.forEach(ing => {
if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null }) if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null })
}) })
return { summaryLines: lines, totalPrice: price * qty } return { summaryLines: lines, totalPrice: price * qty }
@@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const prefChoices = preferenceSets.flatMap(ps => { const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id] const choice = prefs[ps.id]
if (!choice) return [] if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
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 (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 return entries
}) })

View File

@@ -1,26 +1,112 @@
import { useRef } from 'react' import { useRef, useState } from 'react'
function fmtPrice(v) { function fmtPrice(v) {
return Number(v).toFixed(2) + ' €' return Number(v).toFixed(2) + ' €'
} }
// ── Icons ─────────────────────────────────────────────────────────────────────
function SectionIcon({ type }) {
const icons = {
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
}
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
}
// ── Parse selected_options into grouped sections (same logic as cart) ────────
function buildSections(item) {
const sections = []
const opts = (() => {
try { return item.selected_options ? JSON.parse(item.selected_options) : [] } catch { return [] }
})()
const removed = (() => {
try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] }
})()
// We don't have product metadata here, so we classify by heuristics:
// - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them
// - id == null → sub-choice (follows its parent)
// Strategy: walk through opts in order, attaching sub-choices to their parent,
// then classify parent items: items with a real id that appear multiple times → extra (stacked),
// but without product metadata we can't fully distinguish prefs from extras.
// We use a simple rule: if an option with id appears only once in the stream → treat as pref
// (since extras can be added multiple times). This matches how handleAdd() emits them.
const prefGroups = [] // { setName: null (unknown), values: [...] }
const extraGroups = [] // { id, name, subName, qty }
const quickLines = [] // { name, _qty }
// Count how many times each id appears (extras can be stacked → appear multiple times)
const idCount = {}
opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 })
// Single pass: consume each item and its optional following sub (id=null)
const consumedAsSubAtIndex = new Set()
let i = 0
while (i < opts.length) {
const o = opts[i]
if (consumedAsSubAtIndex.has(i)) { i++; continue }
if (o.id == null) {
// Standalone id=null → quick option
const existing = quickLines.find(x => x.name === o.name)
if (existing) existing._qty = (existing._qty || 1) + 1
else quickLines.push({ name: o.name, _qty: 1 })
i++
continue
}
// id != null — look ahead for immediate sub
let subName = null
if (i + 1 < opts.length && opts[i + 1].id == null) {
subName = opts[i + 1].name
consumedAsSubAtIndex.add(i + 1)
}
if (idCount[o.id] > 1) {
// Extra — appears multiple times in the list
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
if (existing) existing.qty++
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
} else {
// Single occurrence → preference choice
const value = subName ? `${o.name} · ${subName}` : o.name
prefGroups.push({ setName: null, values: [value] })
}
i++
}
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
if (quickLines.length > 0) sections.push({ type: 'quick', lines: quickLines })
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
if (removed.length > 0) sections.push({ type: 'removed', lines: removed.map(n => ({ name: n })) })
if (item.notes) sections.push({ type: 'note', lines: [{ name: item.notes }] })
return sections
}
// ── ItemRow ───────────────────────────────────────────────────────────────────
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) { function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
const isPaid = item.status === 'paid' const isPaid = item.status === 'paid'
const isCancelled = item.status === 'cancelled' const isCancelled = item.status === 'cancelled'
const isStacked = item.quantity > 1
let opts = [] const sections = buildSections(item)
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {} const hasDetails = sections.length > 0
let removed = [] const [expanded, setExpanded] = useState(false)
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
// Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll) // Long-press detection
const pressTimer = useRef(null) const pressTimer = useRef(null)
const didLongPress = useRef(false) const didLongPress = useRef(false)
const touchStartPos = useRef({ x: 0, y: 0 }) const touchStartPos = useRef({ x: 0, y: 0 })
function handleTouchStart(e) { function handleTouchStart(e) {
if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return if (!selectable || isPaid || isCancelled || !onLongPress) return
didLongPress.current = false didLongPress.current = false
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY } touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
pressTimer.current = setTimeout(() => { pressTimer.current = setTimeout(() => {
@@ -35,11 +121,9 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current) if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
} }
function handleTouchEnd() { function handleTouchEnd() { clearTimeout(pressTimer.current) }
clearTimeout(pressTimer.current)
}
function handleClick() { function handleBodyClick() {
if (didLongPress.current) { didLongPress.current = false; return } if (didLongPress.current) { didLongPress.current = false; return }
if (selectable && !isPaid && !isCancelled) onToggle(item.id) if (selectable && !isPaid && !isCancelled) onToggle(item.id)
} }
@@ -47,31 +131,115 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
return ( return (
<div <div
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`} className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
onClick={handleClick} style={{ userSelect: 'none' }}
>
{/* Main row — click to select */}
<div
onClick={handleBodyClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd} onTouchCancel={handleTouchEnd}
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
}}
> >
<div className="order-item__row"> {/* Selection checkbox */}
{selectable && !isPaid && !isCancelled && ( {selectable && !isPaid && !isCancelled && (
<span style={{ marginRight: 8, color: selected ? '#f59e0b' : '#475569' }}> <span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
{selected ? '☑' : '☐'} {selected ? '☑' : '☐'}
</span> </span>
)} )}
{/* Name + badges */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span> <span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
<span className="order-item__qty">×{item.quantity}</span>
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
{isPaid && <span className="badge badge--paid">Paid</span>} {isPaid && <span className="badge badge--paid">Paid</span>}
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>} {isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
{!isPaid && !isCancelled && !item.printed && ( {!isPaid && !isCancelled && !item.printed && (
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα"></span> <span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα"></span>
)} )}
</div> </div>
{opts.map((o, i) => <div key={i} className="order-item__modifier">+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}</div>)} </div>
{removed.map((r, i) => <div key={i} className="order-item__modifier">- {r}</div>)}
{item.notes && <div className="order-item__modifier">📝 {item.notes}</div>} {/* Qty + price */}
<span className="order-item__qty">×{item.quantity}</span>
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
{hasDetails && (
<button
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
style={{
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
{/* Expanded details */}
{expanded && hasDetails && (
<div style={{ paddingBottom: 8 }}>
{sections.map((sec, si) => (
<div key={si}>
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{sec.type === 'prefs' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<SectionIcon type="prefs" />
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
{line.setName && (
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
)}
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
</span>
</div>
))}
{sec.type === 'quick' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="quick" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
{line.name}
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
</span>
</div>
))}
{sec.type === 'extras' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="extras" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
{line.name}
{line.subName && <span> · {line.subName}</span>}
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
</span>
</div>
))}
{sec.type === 'removed' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="removed" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
</div>
))}
{sec.type === 'note' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<SectionIcon type="note" />
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -31,6 +31,20 @@ export default function AddItemsPage() {
setCategories(catRes.data) setCategories(catRes.data)
setProducts(prodRes.data) setProducts(prodRes.data)
setOrderId(statusRes.data.active_order_id) setOrderId(statusRes.data.active_order_id)
// Pre-populate cart from "order again" if present
const stored = sessionStorage.getItem('orderAgainItems')
if (stored) {
sessionStorage.removeItem('orderAgainItems')
try {
const items = JSON.parse(stored)
const initialCart = items.map(it => ({
...it,
_key: Date.now() + Math.random(),
}))
setCart(initialCart)
} catch {}
}
} }
load() load()
}, [tableId]) }, [tableId])
@@ -139,28 +153,65 @@ export default function AddItemsPage() {
const sections = [] const sections = []
if (item.selected_options?.length) { if (item.selected_options?.length) {
// Group consecutive options into logical sections by type
// Prefs: options that match a preference choice (have a real id matching preference_sets choices)
const prefIds = new Set( const prefIds = new Set(
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id)) (product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
) )
// Build a map: prefChoiceId → preference set name
const prefSetByChoiceId = {}
;(product?.preference_sets || []).forEach(ps => {
ps.choices.forEach(c => { prefSetByChoiceId[c.id] = ps.name })
})
const quickNames = new Set((product?.quick_options || []).map(o => o.name)) const quickNames = new Set((product?.quick_options || []).map(o => o.name))
const extraIds = new Set((product?.options || []).map(o => o.id)) const extraIds = new Set((product?.options || []).map(o => o.id))
const prefLines = [] // Group prefs: { prefSetName, choiceName, subName }
const prefGroups = []
// Group extras: { name, subName, qty } — one entry per unique (id)
const extraGroups = []
const quickLines = [] const quickLines = []
const extraLines = []
item.selected_options.forEach(o => { let i = 0
if (prefIds.has(o.id)) prefLines.push(o) const opts = item.selected_options
else if (o.id != null && extraIds.has(o.id)) extraLines.push(o) while (i < opts.length) {
else if (quickNames.has(o.name)) quickLines.push(o) const o = opts[i]
else if (o.id == null) { if (prefIds.has(o.id)) {
// sub-choice — attach to last extra or pref line // Collect sub immediately following (id === null)
if (extraLines.length > 0) extraLines.push({ ...o, _sub: true }) const setName = prefSetByChoiceId[o.id] ?? ''
else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true }) let subName = null
if (i + 1 < opts.length && opts[i + 1].id == null) {
subName = opts[i + 1].name
i += 2
} else {
i++
}
// Merge into existing prefGroup for same setName, or create new
const existing = prefGroups.find(g => g.setName === setName)
if (existing) {
// multiple choices from same set (shouldn't normally happen, but handle gracefully)
existing.values.push(subName ? `${o.name} · ${subName}` : o.name)
} else {
prefGroups.push({ setName, values: [subName ? `${o.name} · ${subName}` : o.name] })
}
} else if (o.id != null && extraIds.has(o.id)) {
// Collect sub immediately following
let subName = null
if (i + 1 < opts.length && opts[i + 1].id == null) {
subName = opts[i + 1].name
i += 2
} else {
i++
}
// Merge duplicates
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
if (existing) existing.qty++
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
} else if (quickNames.has(o.name)) {
quickLines.push(o)
i++
} else {
i++
}
} }
})
// Deduplicate quick lines: multiple entries of same name → single entry with qty // Deduplicate quick lines: multiple entries of same name → single entry with qty
const quickDeduped = [] const quickDeduped = []
@@ -170,9 +221,9 @@ export default function AddItemsPage() {
else quickDeduped.push({ ...o, _qty: 1 }) else quickDeduped.push({ ...o, _qty: 1 })
}) })
if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines }) if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped }) if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines }) if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
} }
if (item.removed_ingredients?.length) { if (item.removed_ingredients?.length) {
@@ -196,7 +247,7 @@ export default function AddItemsPage() {
else lines.push(o.name) else lines.push(o.name)
}) })
} }
if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`) if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`)
if (item.notes) lines.push(item.notes) if (item.notes) lines.push(item.notes)
return lines return lines
} }
@@ -502,29 +553,48 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
<div style={{ paddingBottom: 10 }}> <div style={{ paddingBottom: 10 }}>
{sections.map((sec, si) => ( {sections.map((sec, si) => (
<div key={si}> <div key={si}>
{/* Divider between sections */}
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} /> <div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{sec.lines.map((line, li) => ( {sec.type === 'prefs' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}> <div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<SectionIcon type={line._sub ? 'quick' : sec.type} /> <SectionIcon type="prefs" />
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1 }}> <span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
{sec.type === 'note' ? line.name : ( <span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
<> <span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
</span>
</div>
))}
{sec.type === 'quick' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="quick" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
{line.name} {line.name}
{line._qty > 1 && ( {line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
<span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>
)}
{line.price_delta !== 0 && line.price_delta != null && (
<span style={{ color: 'var(--muted)', marginLeft: 4 }}>
({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} )
</span> </span>
)} </div>
</> ))}
)} {sec.type === 'extras' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="extras" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
{line.name}
{line.subName && <span> · {line.subName}</span>}
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
</span> </span>
</div> </div>
))} ))}
{sec.type === 'removed' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<SectionIcon type="removed" />
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
</div>
))}
{sec.type === 'note' && sec.lines.map((line, li) => (
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<SectionIcon type="note" />
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
</div>
))}
</div> </div>
</div> </div>
))} ))}

View File

@@ -81,6 +81,80 @@ function SplitModal({ item, onConfirm, onClose }) {
) )
} }
// ─── Item action modal (long-press) ──────────────────────────────────────────
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
const { items, singleStacked, multiSelect } = target
const label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα`
: items[0]?.product?.name || `#${items[0]?.product_id}`
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
<div className="modal-handle" />
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
<button
onClick={onOrderAgain}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(245,158,11,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
{singleStacked && !multiSelect && (
<button
onClick={onSplit}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(96,165,250,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}></span>
</button>
)}
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
Άκυρο
</button>
</div>
</div>
)
}
// ─── Actions top sheet ──────────────────────────────────────────────────────── // ─── Actions top sheet ────────────────────────────────────────────────────────
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) { function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
@@ -429,6 +503,7 @@ export default function TableDetailPage() {
const [allWaiters, setAllWaiters] = useState([]) const [allWaiters, setAllWaiters] = useState([])
const [actionDataLoading, setActionDataLoading] = useState(false) const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null) const [splitItem, setSplitItem] = useState(null)
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
const scrollRef = useRef(null) const scrollRef = useRef(null)
@@ -800,7 +875,15 @@ export default function TableDetailPage() {
selectable={canInteract && !paying} selectable={canInteract && !paying}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleItem} onToggle={toggleItem}
onLongPressItem={(item) => { setSplitItem(item) }} onLongPressItem={(item) => {
// If multiple items are selected, order-again all selected items
if (selectedIds.length > 1) {
const items = activeItems.filter(i => selectedIds.includes(i.id))
setItemActionTarget({ items, singleStacked: false, multiSelect: true })
} else {
setItemActionTarget({ items: [item], singleStacked: item.quantity > 1, multiSelect: false })
}
}}
/> />
{/* Floating controls row — only visible when items are selected */} {/* Floating controls row — only visible when items are selected */}
@@ -937,6 +1020,32 @@ export default function TableDetailPage() {
</div> </div>
)} )}
{/* Item action modal (long-press) */}
{itemActionTarget && (
<ItemActionModal
target={itemActionTarget}
onOrderAgain={() => {
const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify(
items.map(it => ({
product_id: it.product_id,
quantity: it.quantity,
selected_options: (() => { try { return JSON.parse(it.selected_options || '[]') } catch { return [] } })(),
removed_ingredients: (() => { try { return JSON.parse(it.removed_ingredients || '[]') } catch { return [] } })(),
notes: it.notes || '',
}))
))
setItemActionTarget(null)
navigate(`/tables/${tableId}/add`)
}}
onSplit={() => {
setSplitItem(itemActionTarget.items[0])
setItemActionTarget(null)
}}
onClose={() => setItemActionTarget(null)}
/>
)}
{/* Split stepper modal */} {/* Split stepper modal */}
{splitItem && ( {splitItem && (
<SplitModal <SplitModal