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")
_t(p, sample_lower + "\n")
# Reset
_reset(p)
_t(p, "\n")
_divider(p)
# ── Pages 14: ESC ! modes ────────────────────────────────────────────────────
def page_esc_bang(font_b: bool, english: bool):
font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
lang_label = "GREEK" if not english else "ENGLISH"
p = _open()
# Select font
p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
_page_header(p, f"ESC! MODES — {lang_label}{font_label[:6]}")
_t(p, f"Font: {font_label}\n")
_divider(p)
_esc_bang_section(p, english)
p._raw(b'\n\n\n')
p.cut()
p.close()
# ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
# Combinations worth seeing: square multipliers + some asymmetric
GS_SIZES = [
(0x00, "1x1 normal"),
(0x01, "1w x 2h"),
(0x10, "2w x 1h"),
(0x11, "2x2"),
(0x22, "3x3"),
(0x33, "4x4"),
(0x44, "5x5"),
(0x55, "6x6"),
(0x02, "1w x 3h"),
(0x20, "3w x 1h"),
(0x21, "3w x 2h"),
(0x12, "2w x 3h"),
]
def page_gs_sizes():
p = _open()
_page_header(p, "GS! SIZE MULTIPLIERS")
_t(p, "GS ! n (0x1D 0x21 n)\n")
_t(p, "Low nibble=height, High nibble=width\n")
_divider(p)
for (byte_val, label) in GS_SIZES:
_left(p)
# Label in tiny normal text
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1d\x21\x00')
_t(p, f"[n=0x{byte_val:02X} {label}]\n")
# Font A sample
p._raw(b'\x1b\x4d\x00')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "Aa SAMPLE\n")
p._raw(b'\x1d\x21\x00')
# Font B sample on same size
p._raw(b'\x1b\x4d\x01')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "Bb SMALL\n")
p._raw(b'\x1d\x21\x00')
p._raw(b'\x1b\x4d\x00') # back to Font A
_t(p, "\n")
_divider(p)
# Also show GS ! combined with ESC ! bold
_t(p, "\n")
_divider(p, "=")
p._raw(b'\x1b\x21\x00')
_t(p, "GS! + ESC E bold combined:\n")
_divider(p, "=")
for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1d\x21\x00')
_t(p, f"[{label} + bold]\n")
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1d, 0x21, byte_val]))
_t(p, "BOLD LARGE\n")
p._raw(b'\x1b\x45\x00') p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00') p._raw(b'\x1d\x21\x00')
_t(p, "\n") _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) _divider(p)
p._raw(b'\n')
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
def section_ascii_symbols(p):
_center(p)
p._raw(b'\x1b\x21\x18') # double-height bold for header
_t(p, "=== ASCII SYMBOLS ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# Printable ASCII 0x20 0x7E, 16 per line
chars = [chr(c) for c in range(0x20, 0x7F)]
line = ""
for i, ch in enumerate(chars):
line += ch + " "
if (i + 1) % 24 == 0:
_t(p, line + "\n")
line = ""
if line:
_t(p, line + "\n")
# ── Underline ──
_t(p, "\nUNDERLINE\n")
_divider(p, "-")
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: Hello World 123\n")
p._raw(b'\x1b\x2d\x00')
_t(p, "\n") _t(p, "\n")
_t(p, "Notable:\n")
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
_t(p, " Currency : $ %\n")
_divider(p) _divider(p)
p._raw(b'\n')
# ── Section 4 — CP737 extended chars (0x800xFF) ─────────────────────────── # ── White-on-black invert ──
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
_divider(p, "-")
p._raw(b'\x1d\x42\x01')
_t(p, " INVERTED NORMAL \n")
p._raw(b'\x1d\x21\x11') # 2x2 inverted
_t(p, " INVERTED 2x2 \n")
p._raw(b'\x1d\x21\x00')
p._raw(b'\x1d\x42\x00')
_t(p, "Normal after invert\n")
_divider(p)
def section_extended(p): # ── 90-degree rotation ──
_center(p) _t(p, "\n90-DEGREE ROTATION (ESC V)\n")
p._raw(b'\x1b\x21\x18') _divider(p, "-")
_t(p, "=== CP737 EXTENDED (0x80-FF) ===\n") p._raw(b'\x1b\x56\x01')
p._raw(b'\x1b\x21\x00') _t(p, "ROTATED TEXT\n")
_divider(p, "=") p._raw(b'\x1b\x56\x00')
_left(p) _t(p, "Normal again\n")
_t(p, "Hex offset rows x16 columns\n\n") _divider(p)
for row in range(8): # 0x80, 0x90 ... 0xF0 # ── CP737 useful symbols at normal size ──
base = 0x80 + row * 16 _t(p, "\nUSEFUL CP737 SYMBOLS\n")
row_bytes = bytes(range(base, base + 16)) _divider(p, "-")
label = f"0x{base:02X}: " symbols = [
p._raw(_gr(label)) (0xFB, "tick / checkmark"),
p._raw(row_bytes) (0xFE, "filled square"),
p._raw(b'\n') (0xF9, "middle dot"),
(0xFA, "small bullet"),
_t(p, "\n") (0xF8, "degree"),
_t(p, "Key CP737 specials:\n") (0xDB, "full block"),
specials = [ (0xDC, "lower half block"),
(0xB3, "─ thin horiz line"), (0xDF, "upper half block"),
(0xC4, "─ double line"), (0xB0, "light shade"),
(0xBA, "│ vert line"), (0xB1, "medium shade"),
(0xBB, "┐ corner"), (0xB2, "dark shade"),
(0xBC, "┘ corner"), (0xC4, "thin horiz line"),
(0xC9, "╔ corner"), (0xCD, "double horiz line"),
(0xCA, "╩ junction"), (0xBA, "vertical bar"),
(0xCB, "╦ junction"), (0xC9, "top-left corner dbl"),
(0xCC, "╠ junction"), (0xBB, "top-right corner dbl"),
(0xCD, "═ double horiz"), (0xC8, "bot-left corner dbl"),
(0xCE, "╬ cross"), (0xBC, "bot-right corner dbl"),
(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: for code, desc in symbols:
row_bytes = bytes([code, 0x20]) # char + space p._raw(bytes([code, 0x20, code, 0x20, code, 0x20]))
p._raw(row_bytes)
_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