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