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",
# Auto-expand flag for sub-categories on the PWA accordion
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
# Printer protocol field
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
# Compact (half-width) display flag for quick options
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
]
for sql in migrations:
try:

View File

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

View File

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

View File

@@ -1,305 +1,343 @@
"""
Printer font & symbol test script.
Usage (inside Docker): python print_test.py [IP] [PORT]
Defaults to 10.98.20.25:9100
Printer comprehensive test script — Jolimark TP850UE
Usage: python print_test.py [IP] [PORT]
Default: 10.98.20.25:9100
Prints 6 pages:
Page 1 — ESC ! modes, Font A, English
Page 2 — ESC ! modes, Font B, English
Page 3 — ESC ! modes, Font A, Greek
Page 4 — ESC ! modes, Font B, Greek
Page 5 — GS ! character size multipliers (both fonts)
Page 6 — Beep tests + misc (underline, invert, symbols)
ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
Bit 0 (0x01) — Font B instead of Font A
Bit 3 (0x08) — Emphasize / Bold
Bit 4 (0x10) — Double-height
Bit 5 (0x20) — Double-width
Bit 7 (0x80) — Underline
GS ! (0x1D 0x21 n) character size multiplier:
Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
"""
import sys
import time
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
from escpos.printer import Network
# ── Low-level helpers ──────────────────────────────────────────────────────────
def _gr(text: str) -> bytes:
return text.encode('cp737', errors='replace')
def _open():
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
p._raw(b'\x1b\x40') # ESC @ reset
p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
p._raw(b'\x1b\x40') # ESC @ — full reset
p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
return p
def _t(p, text: str):
p._raw(_gr(text))
def _reset(p):
"""Reset to: Font A, normal size, no bold, left-align."""
p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
def _center(p): p._raw(b'\x1b\x61\x01')
def _left(p): p._raw(b'\x1b\x61\x00')
def _divider(p, char="-", width=48):
p._raw(b'\x1b\x61\x00')
_left(p)
_t(p, char * width + "\n")
def _center(p):
p._raw(b'\x1b\x61\x01')
def _page_header(p, title: str):
_center(p)
p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
_t(p, title + "\n")
_reset(p)
_divider(p, "=")
def _left(p):
p._raw(b'\x1b\x61\x00')
# ── ESC ! n byte reference ──────────────────────────────────────────────────
# Bit 0 → underline (not tested, minor)
# Bit 1 → double-strike (bold)
# Bit 3 → double-height
# Bit 4 → double-width
# Bit 5 → delete-line
# Bit 7 → bold (ESC E alias in some models)
# Common combos used here:
# 0x00 = normal
# 0x08 = double-height only (48 chars wide)
# 0x10 = double-height only (alt bit) (48 chars wide)
# 0x18 = double-height + bold
# 0x20 = double-width only (24 chars wide)
# 0x30 = double-width + double-height (24 chars wide)
# 0x38 = double-width + double-height + bold
# 0x48 = double-height (bit 6 combo — some printers)
# ── ESC ! mode table ───────────────────────────────────────────────────────────
#
# Each entry: (esc_bang_byte, esc_e_bold, label)
# esc_bang_byte sets the mode via ESC ! n
# esc_e_bold adds ESC E on top (independent bold layer)
# We test every useful combination so you can see the exact visual result.
MODES = [
(0x00, "Normal (0x00)"),
(0x08, "Double-height bit3 (0x08)"),
(0x10, "Double-height bit4 (0x10)"),
(0x18, "Double-height + Bold (0x18)"),
(0x20, "Double-width (0x20)"),
(0x30, "Double-width + Double-height (0x30)"),
(0x38, "Double-width + Double-height + Bold (0x38)"),
ESC_BANG_MODES = [
# (byte, extra_bold, label)
(0x00, False, "0x00 Normal"),
(0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
(0x08, False, "0x08 Bold only (bit3)"),
(0x10, False, "0x10 Double-height (bit4)"),
(0x10, True, "0x10 +ESC E Double-height + Bold"),
(0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
(0x20, False, "0x20 Double-width (bit5)"),
(0x20, True, "0x20 +ESC E Double-width + Bold"),
(0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
(0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
(0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
]
# ── Section 1 — Font sizes & styles, English ───────────────────────────────
def section_english(p):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (EN) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
def _esc_bang_section(p, english: bool):
lang = "EN" if english else "GR"
sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
for code, label in MODES:
for (byte_val, extra_bold, label) in ESC_BANG_MODES:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "TEST PRINT Hello World Abc123\n")
# Print the label in small normal text first
p._raw(b'\x1b\x21\x00')
# bold on/off via ESC E
_t(p, " -> bold ON: ")
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold Sample\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
def section_greek(p):
_center(p)
p._raw(b'\x1b\x21\x38')
_t(p, "=== FONT SIZES (GR) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
for code, label in MODES:
_left(p)
_t(p, f"[{label}]\n")
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
_t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
p._raw(b'\x1b\x21\x00')
# Apply mode
p._raw(bytes([0x1b, 0x21, byte_val]))
if extra_bold:
p._raw(b'\x1b\x45\x01')
p._raw(bytes([0x1b, 0x21, code]))
_t(p, "Bold: Καλημερα Κοσμε\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_t(p, sample_normal + "\n")
_t(p, sample_lower + "\n")
# Reset
_reset(p)
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 3 — All printable ASCII symbols ────────────────────────────────
def section_ascii_symbols(p):
_center(p)
p._raw(b'\x1b\x21\x18') # double-height bold for header
_t(p, "=== ASCII SYMBOLS ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# ── Pages 14: ESC ! modes ────────────────────────────────────────────────────
# 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")
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()
_t(p, "\n")
_t(p, "Notable:\n")
_t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
_t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
_t(p, " Currency : $ %\n")
# 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)
p._raw(b'\n')
# ── Section 4 — CP737 extended chars (0x800xFF) ───────────────────────────
_esc_bang_section(p, english)
def section_extended(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== CP737 EXTENDED (0x80-FF) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
_t(p, "Hex offset rows x16 columns\n\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
for row in range(8): # 0x80, 0x90 ... 0xF0
base = 0x80 + row * 16
row_bytes = bytes(range(base, base + 16))
label = f"0x{base:02X}: "
p._raw(_gr(label))
p._raw(row_bytes)
p._raw(b'\n')
_t(p, "\n")
_t(p, "Key CP737 specials:\n")
specials = [
(0xB3, "─ thin horiz line"),
(0xC4, "─ double line"),
(0xBA, "│ vert line"),
(0xBB, "┐ corner"),
(0xBC, "┘ corner"),
(0xC9, "╔ corner"),
(0xCA, "╩ junction"),
(0xCB, "╦ junction"),
(0xCC, "╠ junction"),
(0xCD, "═ double horiz"),
(0xCE, "╬ cross"),
(0xC8, "╚ corner"),
(0xBB, "╗ corner"),
(0xBC, "╝ corner"),
(0xDB, "█ full block"),
(0xDC, "▄ lower block"),
(0xDF, "▀ upper block"),
(0xB0, "░ light shade"),
(0xB1, "▒ medium shade"),
(0xB2, "▓ dark shade"),
(0xF8, "° degree"),
(0xF9, "· middle dot"),
(0xFA, "· bullet dot"),
(0xFB, "√ check / tick"),
(0xFE, "■ filled square"),
# ── 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"),
]
for code, desc in specials:
row_bytes = bytes([code, 0x20]) # char + space
p._raw(row_bytes)
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'\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")
_divider(p)
p._raw(b'\n')
# ── Section 5 — Underline ──────────────────────────────────────────────────
p._raw(b'\n\n\n')
p.cut()
p.close()
def section_underline(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== UNDERLINE TEST ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# ESC - n : underline 0=off, 1=thin, 2=thick
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
_t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
p._raw(b'\x1b\x2d\x00') # off
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
def section_invert(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== INVERT (white-on-black) ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
_left(p)
# GS B n — invert
p._raw(b'\x1d\x42\x01')
_t(p, " INVERTED TEXT SAMPLE \n")
p._raw(b'\x1d\x42\x00')
_t(p, "Normal text after invert\n")
_t(p, "\n")
_divider(p)
p._raw(b'\n')
# ── Section 7 — QR Code sample ────────────────────────────────────────────
def section_qr(p):
_center(p)
p._raw(b'\x1b\x21\x18')
_t(p, "=== QR CODE SAMPLE ===\n")
p._raw(b'\x1b\x21\x00')
_divider(p, "=")
data = b"https://pos.test"
# GS ( k — QR store data
store_len = len(data) + 3
p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data)
# GS ( k — set size (module=6)
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06')
# GS ( k — error correction level M
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31')
# GS ( k — print
p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30')
_t(p, "\nhttps://pos.test\n\n")
_divider(p)
p._raw(b'\n')
# ── Main ───────────────────────────────────────────────────────────────────
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...")
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
print("Printing 6 pages...\n")
# ---- PAGE 1: English fonts ----
p = _open()
section_english(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 1 sent (English fonts)")
page_esc_bang(font_b=False, english=True)
print("Page 1 done — ESC! modes, Font A, English")
# ---- PAGE 2: Greek fonts ----
p = _open()
section_greek(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 2 sent (Greek fonts)")
page_esc_bang(font_b=True, english=True)
print("Page 2 done — ESC! modes, Font B, English")
# ---- PAGE 3: Symbols & special chars ----
p = _open()
section_ascii_symbols(p)
section_extended(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 3 sent (ASCII + CP737 extended)")
page_esc_bang(font_b=False, english=False)
print("Page 3 done — ESC! modes, Font A, Greek")
# ---- PAGE 4: Underline + Invert + QR ----
p = _open()
section_underline(p)
section_invert(p)
section_qr(p)
p._raw(b'\n\n\n')
p.cut()
p.close()
print("Page 4 sent (underline / invert / QR)")
page_esc_bang(font_b=True, english=False)
print("Page 4 done — ESC! modes, Font B, Greek")
print("Done — 4 pages printed.")
page_gs_sizes()
print("Page 5 done — GS! size multipliers")
page_beep_misc()
print("Page 6 done — Beep tests + misc")
print("\nAll done.")
if __name__ == "__main__":
main()

View File

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

View File

@@ -17,6 +17,13 @@ VALID_SETTINGS = {
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
# Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1
"print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')",
"print.font_options": "Font for option/modifier lines: SIZE:BOLD",
"print.font_table": "Font for table/waiter header lines: SIZE:BOLD",
"print.font_order_number": "Font for order number header: SIZE:BOLD",
"print.font_header": "Font for top header block: SIZE:BOLD",
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
}
DEFAULTS = {
@@ -26,6 +33,12 @@ DEFAULTS = {
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
"dev.spoof_printing": "false",
"print.font_item_name": "16:0", # double-height, no bold
"print.font_options": "0:0", # normal
"print.font_table": "16:0", # double-height
"print.font_order_number": "48:1", # double-height + double-width + bold
"print.font_header": "48:1", # double-height + double-width + bold
"print.divider_style": "dash",
}

View File

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

View File

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

View File

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

View File

@@ -47,9 +47,57 @@ def _raw_text(p: Network, text: str):
p._raw(_gr(text))
def _divider(p: Network):
_DIVIDER_CHARS = {
"dash": "-",
"equals": "=",
"star": "*",
"empty": "",
}
_PRINT_FONT_DEFAULTS = {
"print.font_item_name": "16:0",
"print.font_options": "0:0",
"print.font_table": "16:0",
"print.font_order_number": "48:1",
"print.font_header": "48:1",
"print.divider_style": "dash",
}
# SIZE byte values (ESC ! base, no bold bit):
# 0 = normal
# 16 = double-height (bit4)
# 32 = double-width (bit5)
# 48 = double-height + double-width (bits 4+5)
# Bold is applied separately via ESC E.
def _decode_font(value: str) -> tuple[int, bool]:
"""Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag)."""
try:
parts = str(value).split(":")
size = int(parts[0])
bold = len(parts) > 1 and parts[1] == "1"
return size, bold
except (ValueError, AttributeError):
return 0, False
def _load_print_fonts(db: Session) -> dict:
rows = db.query(PosSettings).filter(
PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys())
).all()
fonts = dict(_PRINT_FONT_DEFAULTS)
for row in rows:
fonts[row.key] = row.value
return fonts
def _divider(p: Network, style: str = "dash"):
char = _DIVIDER_CHARS.get(style, "-")
p._raw(b'\x1b\x61\x00')
p._raw(_gr("-" * LINE_WIDTH + "\n"))
if char:
p._raw(_gr(char * LINE_WIDTH + "\n"))
else:
p._raw(b'\n')
def _item_line(name: str, qty: int) -> str:
@@ -106,34 +154,50 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
# ── Receipt formatting ───────────────────────────────────────────────────────
def _font(p: Network, byte_val: int, bold: bool = False):
p._raw(bytes([0x1b, 0x21, byte_val]))
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
# Header
fonts = _load_print_fonts(db)
div = fonts["print.divider_style"]
sz_order, bold_order = _decode_font(fonts["print.font_order_number"])
sz_table, bold_table = _decode_font(fonts["print.font_table"])
sz_item, bold_item = _decode_font(fonts["print.font_item_name"])
sz_opt, bold_opt = _decode_font(fonts["print.font_options"])
# Header — order number
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x38') # bold + double height + double width
_font(p, sz_order, bold_order)
_raw_text(p, f"Παραγγελια #{order.id}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x45\x00')
_divider(p, div)
# Meta
# Meta — table / waiter / time
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
_font(p, sz_table, bold_table)
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
_raw_text(p, f"Date: {now}\n")
_raw_text(p, f"Table: {order.table_id}\n")
_raw_text(p, f"Waiter: {order.opened_by}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x45\x00')
_divider(p, div)
# Items
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}"
p._raw(b'\x1b\x21\x10')
p._raw(b'\x1b\x45\x01') # bold on
_font(p, sz_item, bold_item)
_raw_text(p, _item_line(name, item.quantity) + "\n")
p._raw(b'\x1b\x45\x00') # bold off
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_font(p, sz_opt, bold_opt)
if item.removed_ingredients:
try:
removed_ids = json.loads(item.removed_ingredients)
@@ -154,8 +218,9 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
_raw_text(p, f" (i) {item.notes}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_divider(p)
_divider(p, div)
if order.notes:
p._raw(b'\x1b\x21\x30')

View File

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

View File

@@ -2,10 +2,12 @@ import { useState } from 'react'
import AppInfoTab from './tabs/AppInfoTab'
import ColoursTab from './tabs/ColoursTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import PrintFontsTab from './tabs/PrintFontsTab'
const TABS = [
{ key: 'app-info', label: 'App Info' },
{ key: 'colours', label: 'UI Personalization' },
{ key: 'print-fonts', label: 'Εκτύπωση' },
{ key: 'development', label: 'Development' },
]
@@ -48,6 +50,7 @@ export default function SettingsPage() {
{/* Tab content */}
{activeTab === 'app-info' && <AppInfoTab />}
{activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'development' && <DevelopmentTab />}
</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"
}, {
"url": "index.html",
"revision": "0.qb5i81hq8"
"revision": "0.8icf0qrbd5"
}], {});
workbox.cleanupOutdatedCaches();
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 ──────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) {
function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
const qty = quickState[opt.id] || 0
const selected = qty > 0
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
@@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
</div>
) : null}
>
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>}
<div style={{ fontSize: compact ? 13 : 15, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} {opt.allow_multiple ? ' each' : ''}</div>}
</Row>
)
}
@@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{favorites.map((fav, fi) => {
if (fav.type === 'quick') {
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} />
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} compact={false} />
}
if (fav.type === 'ingredient') {
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
@@ -375,9 +375,11 @@ function QuickTab({ product, quickState, setQuickState }) {
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{quickOptions.map(opt => (
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} />
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} compact={opt.is_compact} />
</div>
))}
</div>
)
@@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) {
{lines.map((l, i) => (
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
<div style={{ flex: 1 }}>
{l.group === 'prefs' ? (
<>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</div>
</>
) : l.group === 'removed' ? (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Χωρίς {l.label}
</div>
) : l.group === 'extras' ? (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{l.label}
{l.subName && <span> · {l.subName}</span>}
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginLeft: 6, fontVariantNumeric: 'tabular-nums' }}>×{l.qty}</span>}
</div>
) : (
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
{l.label}
</div>
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>}
)}
</div>
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} </div>}
</div>
@@ -600,9 +618,23 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null })
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
// Skip if this is entirely the default selection
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
: null
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
: null
const isFullyDefault = isDefaultChoice
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) { price += delta; return }
const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null })
price += delta
})
@@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null })
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null })
price += linePrice
})
ingredients.forEach(ing => {
if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null })
if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null })
})
return { summaryLines: lines, totalPrice: price * qty }
@@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id]
if (!choice) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
// Don't emit entries that are entirely at their defaults — nothing changed
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
: null
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
: null
const isFullyDefault = isDefaultChoice
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
const sharedSub = sharedSubs[ps.id] ?? null
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
}
return entries
})

View File

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

View File

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

View File

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