Compare commits
31 Commits
a01a03623f
...
5da378a0ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 5da378a0ae | |||
| 34b58b3f2e | |||
| f136abe17e | |||
| 0fc027eb5f | |||
| 49f19ed65c | |||
| 492096007b | |||
| 184bee5942 | |||
| 06dccb981e | |||
| 5e28273bb0 | |||
| 9cfbde0e3e | |||
| 2ff48730f7 | |||
| e9bdf61a4d | |||
| d39382e8dd | |||
| 9da396b82a | |||
| a675c2e091 | |||
| cc356077ba | |||
| b15fb27a37 | |||
| e5b8ef32be | |||
| d0dfd63415 | |||
| ac7ec45279 | |||
| 79bd3b0f41 | |||
| aed71a18d8 | |||
| 37fb25c8c2 | |||
| 7958083fd8 | |||
| 3da316ef9b | |||
| 80842d9be3 | |||
| 4b1201ecaa | |||
| 38bef6b1f4 | |||
| 18a75edf2a | |||
| 607e78ea82 | |||
| b9d6055c48 |
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
|||||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||||
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
|
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
|
||||||
VERSION: str = "0.0.0"
|
VERSION: str = "0.0.0"
|
||||||
|
CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders
|
||||||
|
|
||||||
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}
|
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
|
|||||||
430
local_backend/demo_seed.py
Normal file
430
local_backend/demo_seed.py
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Populates the POS database with realistic demo data for customer presentations.
|
||||||
|
|
||||||
|
Generates:
|
||||||
|
- 1 manager account (username: manager / PIN: 1234 / password: password)
|
||||||
|
- 4 demo waiters
|
||||||
|
- 2 table groups with 12 tables total
|
||||||
|
- A full menu: 3 categories, ~20 products
|
||||||
|
- 45 days of backdated history (business days, shifts, orders, payments)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python demo_seed.py
|
||||||
|
|
||||||
|
Must be run from the local_backend directory. Designed to run on a clean DB
|
||||||
|
(after wipe_database.py), but safe to run multiple times — checks for the
|
||||||
|
[DEMO] marker before inserting anything.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import engine, Base, SessionLocal
|
||||||
|
|
||||||
|
import models.user
|
||||||
|
import models.table
|
||||||
|
import models.printer
|
||||||
|
import models.product
|
||||||
|
import models.order
|
||||||
|
import models.business_day
|
||||||
|
import models.shift
|
||||||
|
import models.settings
|
||||||
|
import models.flag
|
||||||
|
import models.message
|
||||||
|
|
||||||
|
from models.user import User, WaiterZone
|
||||||
|
from models.table import TableGroup, Table
|
||||||
|
from models.product import Category, Product
|
||||||
|
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEMO_DAYS = 120 # how many past days to generate (~4 months)
|
||||||
|
ORDERS_PER_DAY = (18, 35) # min/max orders per business day
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _utc(dt: datetime) -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_pin(pin: str) -> str:
|
||||||
|
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(pw: str) -> str:
|
||||||
|
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
|
||||||
|
amount=None, payment_method=None, note=None, created_at=None):
|
||||||
|
log = OrderAuditLog(
|
||||||
|
order_id=order_id,
|
||||||
|
event_type=event_type,
|
||||||
|
waiter_id=waiter_id,
|
||||||
|
item_ids=json.dumps(item_ids) if item_ids is not None else None,
|
||||||
|
amount=amount,
|
||||||
|
payment_method=payment_method,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
if created_at:
|
||||||
|
log.created_at = _utc(created_at)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Menu definition ───────────────────────────────────────────────────────────
|
||||||
|
# (category_name, color, [(product_name, price), ...])
|
||||||
|
MENU = [
|
||||||
|
("Ορεκτικά", "#f97316", [
|
||||||
|
("Τζατζίκι", 4.50),
|
||||||
|
("Ταραμοσαλάτα", 4.50),
|
||||||
|
("Χωριάτικη Σαλάτα", 7.50),
|
||||||
|
("Κεφτεδάκια", 8.00),
|
||||||
|
("Σαγανάκι", 6.50),
|
||||||
|
("Χούμους", 5.00),
|
||||||
|
]),
|
||||||
|
("Κυρίως Πιάτα", "#ef4444", [
|
||||||
|
("Μπριζόλα Χοιρινή", 12.00),
|
||||||
|
("Μπριζόλα Μοσχαρίσια", 16.00),
|
||||||
|
("Σουβλάκι Κοτόπουλο", 10.00),
|
||||||
|
("Μουσακάς", 11.00),
|
||||||
|
("Παστίτσιο", 10.00),
|
||||||
|
("Σπαγγέτι Μπολονέζ", 9.50),
|
||||||
|
("Γύρος Χοιρινός", 8.50),
|
||||||
|
]),
|
||||||
|
("Ποτά & Αναψυκτικά", "#3b82f6", [
|
||||||
|
("Νερό 500ml", 1.00),
|
||||||
|
("Αναψυκτικό", 2.50),
|
||||||
|
("Μπύρα Φιάλη", 4.00),
|
||||||
|
("Κρασί (ποτήρι)", 4.50),
|
||||||
|
("Καφές Ελληνικός", 2.50),
|
||||||
|
("Φραπέ", 3.00),
|
||||||
|
("Χυμός Πορτοκάλι", 3.50),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
|
||||||
|
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
|
||||||
|
|
||||||
|
WAITER_NAMES = [
|
||||||
|
("Νίκος", "Νίκος Π."),
|
||||||
|
("Μαρία", "Μαρία Κ."),
|
||||||
|
("Πέτρος", "Πέτρος Α."),
|
||||||
|
("Έλενα", "Έλενα Μ."),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Guard: abort if demo data already present ─────────────────────────────
|
||||||
|
existing = db.query(User).filter(User.full_name.like("[DEMO]%")).first()
|
||||||
|
if existing:
|
||||||
|
print("Demo data already present (found [DEMO] user). Nothing to do.")
|
||||||
|
print("Run python wipe_database.py first if you want a fresh seed.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print("Creating demo data...")
|
||||||
|
|
||||||
|
# ── Manager account ───────────────────────────────────────────────────────
|
||||||
|
manager = db.query(User).filter(User.username == "manager").first()
|
||||||
|
if not manager:
|
||||||
|
manager = User(
|
||||||
|
username="manager",
|
||||||
|
pin_hash=_hash_pin("1234"),
|
||||||
|
password_hash=_hash_password("password"),
|
||||||
|
role="manager",
|
||||||
|
full_name="Manager",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(manager)
|
||||||
|
db.flush()
|
||||||
|
print(" created manager (username: manager / PIN: 1234 / password: password)")
|
||||||
|
else:
|
||||||
|
# Ensure password_hash is set on existing manager
|
||||||
|
if not manager.password_hash:
|
||||||
|
manager.password_hash = _hash_password("password")
|
||||||
|
db.flush()
|
||||||
|
print(" manager account already exists — updated password to 'password'")
|
||||||
|
|
||||||
|
# ── Waiters ───────────────────────────────────────────────────────────────
|
||||||
|
waiters = []
|
||||||
|
for nickname, full_name in WAITER_NAMES:
|
||||||
|
username = f"demo_{nickname.lower()}"
|
||||||
|
w = db.query(User).filter(User.username == username).first()
|
||||||
|
if not w:
|
||||||
|
w = User(
|
||||||
|
username=username,
|
||||||
|
pin_hash=_hash_pin(str(random.randint(1000, 9999))),
|
||||||
|
role="waiter",
|
||||||
|
full_name=f"[DEMO] {full_name}",
|
||||||
|
nickname=nickname,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(w)
|
||||||
|
db.flush()
|
||||||
|
waiters.append(w)
|
||||||
|
print(f" created {len(waiters)} demo waiters")
|
||||||
|
|
||||||
|
# ── Table groups & tables ─────────────────────────────────────────────────
|
||||||
|
group_configs = [
|
||||||
|
("Κεντρική Αίθουσα", "Κ", "#6366f1", list(range(1, 9))), # tables 1-8
|
||||||
|
("Βεράντα", "Β", "#22c55e", list(range(1, 6))), # tables 1-5
|
||||||
|
]
|
||||||
|
all_tables = []
|
||||||
|
for g_name, g_prefix, g_color, table_numbers in group_configs:
|
||||||
|
grp = db.query(TableGroup).filter(TableGroup.name == g_name).first()
|
||||||
|
if not grp:
|
||||||
|
grp = TableGroup(name=g_name, prefix=g_prefix, color=g_color, sort_order=len(all_tables))
|
||||||
|
db.add(grp)
|
||||||
|
db.flush()
|
||||||
|
for n in table_numbers:
|
||||||
|
tbl = db.query(Table).filter(Table.group_id == grp.id, Table.number == n).first()
|
||||||
|
if not tbl:
|
||||||
|
tbl = Table(number=n, group_id=grp.id, is_active=True)
|
||||||
|
db.add(tbl)
|
||||||
|
db.flush()
|
||||||
|
all_tables.append(tbl)
|
||||||
|
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
|
||||||
|
|
||||||
|
# ── Waiter zone assignments (all waiters → all zones) ─────────────────────
|
||||||
|
for w in waiters:
|
||||||
|
existing_zone = db.query(WaiterZone).filter(
|
||||||
|
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
|
||||||
|
).first()
|
||||||
|
if not existing_zone:
|
||||||
|
db.add(WaiterZone(waiter_id=w.id, group_id=None))
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||||
|
categories = []
|
||||||
|
all_products = [] # flat list for random picks
|
||||||
|
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
|
||||||
|
|
||||||
|
for sort_idx, (cat_name, cat_color, items) in enumerate(MENU):
|
||||||
|
cat = db.query(Category).filter(Category.name == cat_name).first()
|
||||||
|
if not cat:
|
||||||
|
cat = Category(name=cat_name, color=cat_color, sort_order=sort_idx)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
categories.append(cat)
|
||||||
|
|
||||||
|
cat_products = []
|
||||||
|
for prod_sort, (prod_name, prod_price) in enumerate(items):
|
||||||
|
p = db.query(Product).filter(Product.name == prod_name, Product.category_id == cat.id).first()
|
||||||
|
if not p:
|
||||||
|
p = Product(
|
||||||
|
name=prod_name,
|
||||||
|
category_id=cat.id,
|
||||||
|
base_price=prod_price,
|
||||||
|
is_available=True,
|
||||||
|
lifecycle_status="active",
|
||||||
|
sort_order=prod_sort,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
cat_products.append(p)
|
||||||
|
all_products.append(p)
|
||||||
|
category_product_map.append(cat_products)
|
||||||
|
|
||||||
|
print(f" created {len(categories)} categories with {len(all_products)} products")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ── Historical days ───────────────────────────────────────────────────────
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
total_orders = 0
|
||||||
|
total_revenue = 0.0
|
||||||
|
|
||||||
|
print(f"\nGenerating {DEMO_DAYS} days of history...")
|
||||||
|
|
||||||
|
for days_ago in range(DEMO_DAYS, 0, -1):
|
||||||
|
day_date = today - timedelta(days=days_ago)
|
||||||
|
|
||||||
|
# Business day: opens at ~10:00, closes at ~23:30
|
||||||
|
open_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||||
|
10, random.randint(0, 30)))
|
||||||
|
close_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||||
|
23, random.randint(15, 45)))
|
||||||
|
|
||||||
|
bday = BusinessDay(
|
||||||
|
status="closed",
|
||||||
|
opened_at=open_time,
|
||||||
|
opened_by_id=manager.id,
|
||||||
|
closed_at=close_time,
|
||||||
|
closed_by_id=manager.id,
|
||||||
|
)
|
||||||
|
db.add(bday)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 2-4 waiters work each day
|
||||||
|
working_waiters = random.sample(waiters, k=random.randint(2, len(waiters)))
|
||||||
|
STARTING_CASH_OPTIONS = [50.0, 80.0, 100.0, 110.0, 120.0, 150.0, 200.0]
|
||||||
|
shifts = {}
|
||||||
|
shift_totals = {} # shift_id → running revenue total, filled during order loop
|
||||||
|
for w in working_waiters:
|
||||||
|
shift_start = open_time + timedelta(minutes=random.randint(0, 30))
|
||||||
|
shift_end = close_time - timedelta(minutes=random.randint(0, 20))
|
||||||
|
shift = WaiterShift(
|
||||||
|
waiter_id=w.id,
|
||||||
|
business_day_id=bday.id,
|
||||||
|
started_at=shift_start,
|
||||||
|
ended_at=shift_end,
|
||||||
|
starting_cash=random.choice(STARTING_CASH_OPTIONS),
|
||||||
|
)
|
||||||
|
db.add(shift)
|
||||||
|
db.flush()
|
||||||
|
shifts[w.id] = shift
|
||||||
|
shift_totals[shift.id] = 0.0
|
||||||
|
|
||||||
|
# Orders — two rushes: lunch (12-15h) and dinner (19-23h)
|
||||||
|
n_orders = random.randint(*ORDERS_PER_DAY)
|
||||||
|
lunch_count = int(n_orders * 0.4)
|
||||||
|
dinner_count = n_orders - lunch_count
|
||||||
|
|
||||||
|
order_times = []
|
||||||
|
for _ in range(lunch_count):
|
||||||
|
h = random.randint(12, 14)
|
||||||
|
m = random.randint(0, 59)
|
||||||
|
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||||
|
for _ in range(dinner_count):
|
||||||
|
h = random.randint(19, 22)
|
||||||
|
m = random.randint(0, 59)
|
||||||
|
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||||
|
order_times.sort()
|
||||||
|
|
||||||
|
# Track which tables are "busy" at each point in time so we don't double-seat
|
||||||
|
# Format: {table_id: release_time}
|
||||||
|
table_busy_until: dict[int, datetime] = {}
|
||||||
|
|
||||||
|
for opened_at_naive in order_times:
|
||||||
|
opened_at = _utc(opened_at_naive)
|
||||||
|
|
||||||
|
# Pick a free table
|
||||||
|
free_tables = [
|
||||||
|
t for t in all_tables
|
||||||
|
if table_busy_until.get(t.id, datetime.min.replace(tzinfo=timezone.utc)) <= opened_at
|
||||||
|
]
|
||||||
|
if not free_tables:
|
||||||
|
free_tables = all_tables # all busy → just reuse (edge case)
|
||||||
|
table = random.choice(free_tables)
|
||||||
|
|
||||||
|
# Pick a waiter who is working today
|
||||||
|
waiter = random.choice(working_waiters)
|
||||||
|
shift = shifts[waiter.id]
|
||||||
|
|
||||||
|
# Order duration: 20-70 min
|
||||||
|
duration_min = random.randint(20, 70)
|
||||||
|
closed_at = opened_at + timedelta(minutes=duration_min)
|
||||||
|
# Cap to close_time
|
||||||
|
if closed_at > close_time:
|
||||||
|
closed_at = close_time - timedelta(minutes=2)
|
||||||
|
|
||||||
|
table_busy_until[table.id] = closed_at
|
||||||
|
|
||||||
|
order = Order(
|
||||||
|
table_id=table.id,
|
||||||
|
opened_by=waiter.id,
|
||||||
|
business_day_id=bday.id,
|
||||||
|
status="closed",
|
||||||
|
closed_by=manager.id,
|
||||||
|
notes=None,
|
||||||
|
source="pos",
|
||||||
|
)
|
||||||
|
order.opened_at = opened_at
|
||||||
|
order.closed_at = closed_at
|
||||||
|
db.add(order)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(OrderWaiter(
|
||||||
|
order_id=order.id,
|
||||||
|
waiter_id=waiter.id,
|
||||||
|
assigned_at=opened_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
_audit(db, order.id, "ORDER_OPENED", waiter_id=waiter.id, created_at=opened_at)
|
||||||
|
|
||||||
|
# Items: 2-6 items, weighted by category
|
||||||
|
n_items = random.randint(2, 6)
|
||||||
|
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
|
||||||
|
items_added = []
|
||||||
|
order_total = 0.0
|
||||||
|
items_added_at = opened_at + timedelta(minutes=random.randint(2, 8))
|
||||||
|
|
||||||
|
for prod_list in chosen_category_lists:
|
||||||
|
product = random.choice(prod_list)
|
||||||
|
qty = random.choices([1, 2, 3], weights=[0.7, 0.2, 0.1])[0]
|
||||||
|
payment_method = random.choice(PAYMENT_METHODS)
|
||||||
|
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
|
||||||
|
line_total = product.base_price * qty
|
||||||
|
order_total += line_total
|
||||||
|
|
||||||
|
item = OrderItem(
|
||||||
|
order_id=order.id,
|
||||||
|
product_id=product.id,
|
||||||
|
added_by=waiter.id,
|
||||||
|
quantity=qty,
|
||||||
|
unit_price=product.base_price,
|
||||||
|
status="paid",
|
||||||
|
printed=True,
|
||||||
|
paid_by=waiter.id,
|
||||||
|
paid_at=_utc(paid_at),
|
||||||
|
payment_method=payment_method,
|
||||||
|
paid_in_shift_id=shift.id,
|
||||||
|
)
|
||||||
|
item.added_at = _utc(items_added_at)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
items_added.append(item.id)
|
||||||
|
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
|
||||||
|
|
||||||
|
_audit(db, order.id, "ITEMS_ADDED",
|
||||||
|
waiter_id=waiter.id, item_ids=items_added,
|
||||||
|
created_at=items_added_at)
|
||||||
|
|
||||||
|
# Payment audit (single payment per order for simplicity)
|
||||||
|
payment_method = random.choice(PAYMENT_METHODS)
|
||||||
|
pay_at = closed_at - timedelta(minutes=1)
|
||||||
|
_audit(db, order.id, "PAYMENT",
|
||||||
|
waiter_id=waiter.id, item_ids=items_added,
|
||||||
|
amount=round(order_total, 2), payment_method=payment_method,
|
||||||
|
created_at=pay_at)
|
||||||
|
|
||||||
|
_audit(db, order.id, "ORDER_CLOSED",
|
||||||
|
waiter_id=waiter.id, created_at=closed_at)
|
||||||
|
|
||||||
|
total_orders += 1
|
||||||
|
total_revenue += order_total
|
||||||
|
|
||||||
|
# Write total_collected snapshot onto each ended shift
|
||||||
|
for w in working_waiters:
|
||||||
|
s = shifts[w.id]
|
||||||
|
s.total_collected = shift_totals[s.id]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters")
|
||||||
|
|
||||||
|
print(f"\nDone.")
|
||||||
|
print(f" Total orders : {total_orders}")
|
||||||
|
print(f" Total revenue : €{total_revenue:,.2f}")
|
||||||
|
print(f"\nLogin credentials:")
|
||||||
|
print(f" Manager — username: manager | password: password | PIN: 1234")
|
||||||
|
for w in waiters:
|
||||||
|
print(f" Waiter — username: {w.username}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -19,6 +19,13 @@ import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
|
|||||||
import models.settings # noqa: F401
|
import models.settings # noqa: F401
|
||||||
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
||||||
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||||
|
import models.reservation # noqa: F401
|
||||||
|
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||||
|
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
||||||
|
import models.customers # noqa: F401 — registers Customer
|
||||||
|
import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
|
||||||
|
import models.waste # noqa: F401 — registers WasteLog
|
||||||
|
import models.schedule # noqa: F401 — registers ScheduledShift
|
||||||
|
|
||||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||||
from routers import business_day as business_day_router
|
from routers import business_day as business_day_router
|
||||||
@@ -28,6 +35,15 @@ from routers import flags as flags_router
|
|||||||
from routers import messages as messages_router
|
from routers import messages as messages_router
|
||||||
from routers import sse as sse_router
|
from routers import sse as sse_router
|
||||||
from routers import data_transfer as data_transfer_router
|
from routers import data_transfer as data_transfer_router
|
||||||
|
from routers import connect_orders as connect_orders_router
|
||||||
|
from routers import reservations as reservations_router
|
||||||
|
from routers import notes as notes_router
|
||||||
|
from routers.expenses import contacts_router, expenses_router
|
||||||
|
from routers import customers as customers_router
|
||||||
|
from routers import tabs as tabs_router
|
||||||
|
from routers import waste as waste_router
|
||||||
|
from routers import kds as kds_router
|
||||||
|
from routers import schedule as schedule_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -218,6 +234,178 @@ def _run_migrations():
|
|||||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
|
||||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
|
||||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
|
||||||
|
# Xenia Connect — online order fields on orders table
|
||||||
|
"ALTER TABLE orders ADD COLUMN source VARCHAR NOT NULL DEFAULT 'pos'",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_order_ref VARCHAR",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_order_cloud_id INTEGER",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_status VARCHAR",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_customer_name VARCHAR",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_customer_phone VARCHAR",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_customer_address TEXT",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_customer_notes TEXT",
|
||||||
|
"ALTER TABLE orders ADD COLUMN online_order_type VARCHAR",
|
||||||
|
# Xenia Connect — digital menu fields on products
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_visible INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_available INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_name VARCHAR",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_description VARCHAR",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_price REAL",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_discount REAL NOT NULL DEFAULT 0.0",
|
||||||
|
"ALTER TABLE products ADD COLUMN digital_image_url VARCHAR",
|
||||||
|
# Per-printer line width (chars per line) — default 48 for 80mm Jolimark
|
||||||
|
"ALTER TABLE printers ADD COLUMN line_width INTEGER NOT NULL DEFAULT 48",
|
||||||
|
# Product description (plain-text, for menu display)
|
||||||
|
"ALTER TABLE products ADD COLUMN description TEXT",
|
||||||
|
# Staff note (internal memo on a waiter's profile)
|
||||||
|
"ALTER TABLE users ADD COLUMN note VARCHAR",
|
||||||
|
# Reservations table
|
||||||
|
"""CREATE TABLE IF NOT EXISTS reservations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guest_name VARCHAR NOT NULL,
|
||||||
|
party_size INTEGER NOT NULL,
|
||||||
|
phone VARCHAR,
|
||||||
|
email VARCHAR,
|
||||||
|
note TEXT,
|
||||||
|
reserved_for DATETIME NOT NULL,
|
||||||
|
table_id INTEGER REFERENCES tables(id),
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'pending',
|
||||||
|
source VARCHAR NOT NULL DEFAULT 'manager_app',
|
||||||
|
created_by_user_id INTEGER REFERENCES users(id),
|
||||||
|
online_customer_id VARCHAR,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Phase 2A — product cost tracking
|
||||||
|
"ALTER TABLE products ADD COLUMN cost_simple REAL",
|
||||||
|
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
|
||||||
|
"ALTER TABLE order_items ADD COLUMN unit_cost REAL",
|
||||||
|
# Phase 2B — staff payroll
|
||||||
|
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||||
|
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
||||||
|
# Phase 2J — staff shift scheduling
|
||||||
|
"""CREATE TABLE IF NOT EXISTS scheduled_shifts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
scheduled_date DATE NOT NULL,
|
||||||
|
start_time VARCHAR NOT NULL,
|
||||||
|
end_time VARCHAR NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Phase 2H — void/waste log
|
||||||
|
"""CREATE TABLE IF NOT EXISTS waste_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
unit_cost_snapshot REAL,
|
||||||
|
total_cost REAL,
|
||||||
|
reason VARCHAR NOT NULL,
|
||||||
|
reason_notes TEXT,
|
||||||
|
logged_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
business_day_id INTEGER REFERENCES business_days(id),
|
||||||
|
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Phase 2G — pay-later tab system
|
||||||
|
"""CREATE TABLE IF NOT EXISTS tabs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'open',
|
||||||
|
opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
closed_at DATETIME,
|
||||||
|
closed_by_id INTEGER REFERENCES users(id),
|
||||||
|
notes TEXT
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS tab_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tab_id INTEGER NOT NULL REFERENCES tabs(id),
|
||||||
|
order_id INTEGER REFERENCES orders(id),
|
||||||
|
order_item_id INTEGER REFERENCES order_items(id),
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS tab_payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tab_id INTEGER NOT NULL REFERENCES tabs(id),
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
payment_method VARCHAR,
|
||||||
|
received_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Phase 2F — customer CRM
|
||||||
|
"""CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
nickname VARCHAR,
|
||||||
|
phone VARCHAR,
|
||||||
|
email VARCHAR,
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id)
|
||||||
|
)""",
|
||||||
|
"ALTER TABLE orders ADD COLUMN customer_id INTEGER REFERENCES customers(id)",
|
||||||
|
# Phase 2E — cash drawer reconciliation
|
||||||
|
"ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL",
|
||||||
|
"ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy REAL",
|
||||||
|
"ALTER TABLE business_days ADD COLUMN store_opening_cash REAL",
|
||||||
|
"ALTER TABLE business_days ADD COLUMN store_closing_cash REAL",
|
||||||
|
"ALTER TABLE business_days ADD COLUMN store_cash_discrepancy REAL",
|
||||||
|
# Phase 2D — expense tracking + supplier contacts
|
||||||
|
"""CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
type VARCHAR NOT NULL DEFAULT 'supplier',
|
||||||
|
phone VARCHAR,
|
||||||
|
email VARCHAR,
|
||||||
|
address TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
description VARCHAR NOT NULL,
|
||||||
|
category VARCHAR NOT NULL,
|
||||||
|
contact_id INTEGER REFERENCES contacts(id),
|
||||||
|
total_amount REAL NOT NULL,
|
||||||
|
paid_amount REAL NOT NULL DEFAULT 0.0,
|
||||||
|
due_date DATETIME,
|
||||||
|
business_day_id INTEGER REFERENCES business_days(id),
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
notes TEXT
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS expense_payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
expense_id INTEGER NOT NULL REFERENCES expenses(id),
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
paid_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
paid_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
notes TEXT
|
||||||
|
)""",
|
||||||
|
# Phase 2C — notes & todos
|
||||||
|
"""CREATE TABLE IF NOT EXISTS site_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_pinned INTEGER NOT NULL DEFAULT 0
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS site_todos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
done_at DATETIME,
|
||||||
|
done_by_id INTEGER REFERENCES users(id),
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
priority VARCHAR NOT NULL DEFAULT 'normal'
|
||||||
|
)""",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -232,12 +420,15 @@ def _run_migrations():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
import asyncio
|
import asyncio
|
||||||
from services.sse_bus import init_loop
|
from services.sse_bus import init_loop
|
||||||
|
from services.reservation_tasks import start_reservation_tasks
|
||||||
init_loop(asyncio.get_running_loop())
|
init_loop(asyncio.get_running_loop())
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_run_migrations()
|
_run_migrations()
|
||||||
sync_task = await start_cloud_sync()
|
sync_task = await start_cloud_sync()
|
||||||
|
reservation_task = await start_reservation_tasks()
|
||||||
yield
|
yield
|
||||||
sync_task.cancel()
|
sync_task.cancel()
|
||||||
|
reservation_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
|
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
|
||||||
@@ -275,3 +466,13 @@ app.include_router(flags_router.router, prefix="/api/flags", tag
|
|||||||
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
||||||
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||||
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
||||||
|
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
|
||||||
|
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
|
||||||
|
app.include_router(notes_router.router, prefix="/api/notes", tags=["notes"])
|
||||||
|
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
|
||||||
|
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
|
||||||
|
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
||||||
|
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
|
||||||
|
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
|
||||||
|
app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"])
|
||||||
|
app.include_router(schedule_router.router, prefix="/api/schedule", tags=["schedule"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
@@ -18,6 +18,10 @@ class BusinessDay(Base):
|
|||||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
# Phase 2E — store-level cash reconciliation
|
||||||
|
store_opening_cash = Column(Float, nullable=True) # physical cash when day opens
|
||||||
|
store_closing_cash = Column(Float, nullable=True) # physical cash counted at close
|
||||||
|
store_cash_discrepancy = Column(Float, nullable=True) # store_closing - expected
|
||||||
|
|
||||||
opener = relationship("User", foreign_keys=[opened_by_id])
|
opener = relationship("User", foreign_keys=[opened_by_id])
|
||||||
closer = relationship("User", foreign_keys=[closed_by_id])
|
closer = relationship("User", foreign_keys=[closed_by_id])
|
||||||
|
|||||||
25
local_backend/models/customers.py
Normal file
25
local_backend/models/customers.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base):
|
||||||
|
__tablename__ = "customers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
nickname = Column(String, nullable=True)
|
||||||
|
phone = Column(String, nullable=True)
|
||||||
|
email = Column(String, nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
orders = relationship("Order", back_populates="customer")
|
||||||
58
local_backend/models/expenses.py
Normal file
58
local_backend/models/expenses.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base):
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
type = Column(String, default="supplier", nullable=False) # supplier|staff|utility|other
|
||||||
|
phone = Column(String, nullable=True)
|
||||||
|
email = Column(String, nullable=True)
|
||||||
|
address = Column(Text, nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
expenses = relationship("Expense", back_populates="contact")
|
||||||
|
|
||||||
|
|
||||||
|
class Expense(Base):
|
||||||
|
__tablename__ = "expenses"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
description = Column(String, nullable=False)
|
||||||
|
category = Column(String, nullable=False) # supplier|utilities|rent|maintenance|staff|equipment|other
|
||||||
|
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
|
||||||
|
total_amount = Column(Float, nullable=False)
|
||||||
|
paid_amount = Column(Float, default=0.0, nullable=False)
|
||||||
|
due_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
contact = relationship("Contact", back_populates="expenses")
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
payments = relationship("ExpensePayment", back_populates="expense", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ExpensePayment(Base):
|
||||||
|
__tablename__ = "expense_payments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
paid_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
paid_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
expense = relationship("Expense", back_populates="payments")
|
||||||
|
paid_by = relationship("User", foreign_keys=[paid_by_id])
|
||||||
37
local_backend/models/notes.py
Normal file
37
local_backend/models/notes.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteNote(Base):
|
||||||
|
__tablename__ = "site_notes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
is_pinned = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
|
||||||
|
class SiteTodo(Base):
|
||||||
|
__tablename__ = "site_todos"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
is_done = Column(Boolean, default=False, nullable=False)
|
||||||
|
done_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
done_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
priority = Column(String, default="normal", nullable=False) # "normal" | "high"
|
||||||
|
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
done_by = relationship("User", foreign_keys=[done_by_id])
|
||||||
@@ -12,7 +12,7 @@ class Order(Base):
|
|||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True) # nullable for online orders
|
||||||
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
opened_at = Column(DateTime(timezone=True), default=_utcnow)
|
opened_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||||
@@ -20,10 +20,23 @@ class Order(Base):
|
|||||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||||
|
# Xenia Connect — online order fields
|
||||||
|
source = Column(String, default="pos", nullable=False) # "pos" | "online"
|
||||||
|
online_order_ref = Column(String, nullable=True) # e.g. "ORD-0042"
|
||||||
|
online_order_cloud_id = Column(Integer, nullable=True) # cloud DB pk for status mirroring
|
||||||
|
online_status = Column(String, nullable=True) # mirrors cloud status
|
||||||
|
online_customer_name = Column(String, nullable=True)
|
||||||
|
online_customer_phone = Column(String, nullable=True)
|
||||||
|
online_customer_address = Column(Text, nullable=True)
|
||||||
|
online_customer_notes = Column(Text, nullable=True)
|
||||||
|
online_order_type = Column(String, nullable=True) # "delivery" | "dine_in"
|
||||||
|
# Phase 2F — customer CRM
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
||||||
|
|
||||||
table = relationship("Table", back_populates="orders")
|
table = relationship("Table", back_populates="orders")
|
||||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||||
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||||
|
customer = relationship("Customer", back_populates="orders", foreign_keys=[customer_id])
|
||||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||||
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
||||||
@@ -65,6 +78,9 @@ class OrderItem(Base):
|
|||||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||||
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||||
|
|
||||||
|
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
||||||
|
unit_cost = Column(Float, nullable=True)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="items")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class Printer(Base):
|
|||||||
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)
|
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||||
|
line_width = Column(Integer, default=48, 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")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Product(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
base_price = Column(Float, nullable=False)
|
base_price = Column(Float, nullable=False)
|
||||||
is_available = Column(Boolean, default=True, nullable=False)
|
is_available = Column(Boolean, default=True, nullable=False)
|
||||||
@@ -36,6 +37,19 @@ class Product(Base):
|
|||||||
image_url = Column(String, nullable=True)
|
image_url = Column(String, nullable=True)
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Xenia Connect — digital menu overrides
|
||||||
|
digital_visible = Column(Integer, default=1, nullable=False)
|
||||||
|
digital_available = Column(Integer, default=1, nullable=False)
|
||||||
|
digital_name = Column(String, nullable=True)
|
||||||
|
digital_description = Column(String, nullable=True)
|
||||||
|
digital_price = Column(Float, nullable=True)
|
||||||
|
digital_discount = Column(Float, default=0.0, nullable=False)
|
||||||
|
digital_image_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple = Column(Float, nullable=True)
|
||||||
|
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
|
||||||
|
|
||||||
category = relationship("Category", back_populates="products")
|
category = relationship("Category", back_populates="products")
|
||||||
printer_zone = relationship("Printer", back_populates="products")
|
printer_zone = relationship("Printer", back_populates="products")
|
||||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
|
|||||||
42
local_backend/models/reservation.py
Normal file
42
local_backend/models/reservation.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Reservation(Base):
|
||||||
|
__tablename__ = "reservations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Guest info
|
||||||
|
guest_name = Column(String, nullable=False)
|
||||||
|
party_size = Column(Integer, nullable=False)
|
||||||
|
phone = Column(String, nullable=True)
|
||||||
|
email = Column(String, nullable=True)
|
||||||
|
note = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# When the customer wants the table
|
||||||
|
reserved_for = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
# Optional table assignment (assignable/editable later)
|
||||||
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
|
||||||
|
|
||||||
|
# Lifecycle: pending | arrived | cancelled | no_show
|
||||||
|
status = Column(String, nullable=False, default="pending")
|
||||||
|
|
||||||
|
# Source: manager_app | waiter_app | online
|
||||||
|
source = Column(String, nullable=False, default="manager_app")
|
||||||
|
|
||||||
|
# Who entered this reservation (staff user)
|
||||||
|
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# For future online reservations: cloud-side customer identifier
|
||||||
|
online_customer_id = Column(String, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
table = relationship("Table", foreign_keys=[table_id])
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||||
24
local_backend/models/schedule.py
Normal file
24
local_backend/models/schedule.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Date, Time, Text, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledShift(Base):
|
||||||
|
__tablename__ = "scheduled_shifts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
scheduled_date = Column(Date, nullable=False)
|
||||||
|
start_time = Column(String, nullable=False) # "HH:MM" stored as string (SQLite has no Time type)
|
||||||
|
end_time = Column(String, nullable=False)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
@@ -19,6 +19,11 @@ class WaiterShift(Base):
|
|||||||
starting_cash = Column(Float, nullable=True)
|
starting_cash = Column(Float, nullable=True)
|
||||||
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
# Phase 2B — payroll snapshot (copied from user.hourly_rate at shift start, never updated)
|
||||||
|
hourly_rate_snapshot = Column(Float, nullable=True)
|
||||||
|
# Phase 2E — cash reconciliation
|
||||||
|
counted_cash_end = Column(Float, nullable=True) # physical cash waiter hands over
|
||||||
|
cash_discrepancy = Column(Float, nullable=True) # counted_cash_end - expected; neg = short
|
||||||
|
|
||||||
waiter = relationship("User", foreign_keys=[waiter_id])
|
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||||
business_day = relationship("BusinessDay", back_populates="shifts")
|
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||||
|
|||||||
58
local_backend/models/tabs.py
Normal file
58
local_backend/models/tabs.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Tab(Base):
|
||||||
|
__tablename__ = "tabs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||||||
|
status = Column(String, default="open", nullable=False) # open | closed | forgiven
|
||||||
|
opened_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||||
|
closed_by = relationship("User", foreign_keys=[closed_by_id])
|
||||||
|
entries = relationship("TabEntry", back_populates="tab", cascade="all, delete-orphan")
|
||||||
|
payments = relationship("TabPayment", back_populates="tab", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TabEntry(Base):
|
||||||
|
"""What went ON the tab — a deferred charge."""
|
||||||
|
__tablename__ = "tab_entries"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
|
||||||
|
order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
|
||||||
|
amount = Column(Float, nullable=False) # snapshot at time of tab entry
|
||||||
|
description = Column(Text, nullable=True) # auto-generated summary
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
tab = relationship("Tab", back_populates="entries")
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
|
||||||
|
class TabPayment(Base):
|
||||||
|
"""What came OFF the tab — a payment reducing the balance."""
|
||||||
|
__tablename__ = "tab_payments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
payment_method = Column(String, nullable=True) # cash | card | other
|
||||||
|
received_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
tab = relationship("Tab", back_populates="payments")
|
||||||
|
received_by = relationship("User", foreign_keys=[received_by_id])
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
@@ -21,8 +21,11 @@ class User(Base):
|
|||||||
full_name = Column(String, nullable=True)
|
full_name = Column(String, nullable=True)
|
||||||
nickname = Column(String, nullable=True)
|
nickname = Column(String, nullable=True)
|
||||||
mobile_phone = Column(String, nullable=True)
|
mobile_phone = Column(String, nullable=True)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
# Phase 2B — payroll
|
||||||
|
hourly_rate = Column(Float, nullable=True)
|
||||||
|
|
||||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||||
|
|||||||
26
local_backend/models/waste.py
Normal file
26
local_backend/models/waste.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class WasteLog(Base):
|
||||||
|
__tablename__ = "waste_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
quantity = Column(Float, nullable=False) # supports decimals
|
||||||
|
unit_cost_snapshot = Column(Float, nullable=True) # product's effective cost at log time
|
||||||
|
total_cost = Column(Float, nullable=True) # unit_cost_snapshot * quantity
|
||||||
|
reason = Column(String, nullable=False) # kitchen_error|dropped|expired|other
|
||||||
|
reason_notes = Column(Text, nullable=True)
|
||||||
|
logged_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||||
|
logged_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
product = relationship("Product", foreign_keys=[product_id])
|
||||||
|
logged_by = relationship("User", foreign_keys=[logged_by_id])
|
||||||
@@ -6,10 +6,12 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.business_day import BusinessDay
|
from models.business_day import BusinessDay
|
||||||
from models.order import Order, OrderItem, OrderAuditLog
|
from models.order import Order, OrderItem, OrderAuditLog, PrintLog
|
||||||
from models.shift import WaiterShift
|
from models.shift import WaiterShift
|
||||||
from models.flag import TableFlagAssignment
|
from models.flag import TableFlagAssignment
|
||||||
from models.message import StaffMessage, StaffMessageAck
|
from models.message import StaffMessage, StaffMessageAck
|
||||||
|
from models.table import Table, TableGroup
|
||||||
|
from models.printer import Printer
|
||||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from models.user import User
|
from models.user import User
|
||||||
@@ -148,6 +150,15 @@ def close_business_day(
|
|||||||
if body.notes:
|
if body.notes:
|
||||||
day.notes = body.notes
|
day.notes = body.notes
|
||||||
|
|
||||||
|
# Phase 2E: store-level cash reconciliation
|
||||||
|
if body.store_closing_cash is not None:
|
||||||
|
day.store_closing_cash = body.store_closing_cash
|
||||||
|
# expected = store_opening_cash + sum of all waiter collected amounts this day
|
||||||
|
all_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
|
||||||
|
total_collected = sum(s.total_collected or 0.0 for s in all_shifts)
|
||||||
|
expected_closing = (day.store_opening_cash or 0.0) + total_collected
|
||||||
|
day.store_cash_discrepancy = round(body.store_closing_cash - expected_closing, 2)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(day)
|
db.refresh(day)
|
||||||
|
|
||||||
@@ -189,6 +200,325 @@ def delete_business_day(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def business_day_summary(
|
||||||
|
day_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
"""Full summary for a business day: per-waiter, per-zone, per-printer, and checks.
|
||||||
|
If day_id is omitted, uses the currently open business day."""
|
||||||
|
if day_id:
|
||||||
|
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
|
||||||
|
if not day:
|
||||||
|
raise HTTPException(status_code=404, detail="Business day not found")
|
||||||
|
else:
|
||||||
|
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not day:
|
||||||
|
raise HTTPException(status_code=404, detail="No open business day")
|
||||||
|
|
||||||
|
orders = db.query(Order).filter(Order.business_day_id == day.id).all()
|
||||||
|
order_ids = [o.id for o in orders]
|
||||||
|
|
||||||
|
all_items = db.query(OrderItem).filter(OrderItem.order_id.in_(order_ids)).all() if order_ids else []
|
||||||
|
paid_items = [i for i in all_items if i.status == "paid"]
|
||||||
|
active_items = [i for i in all_items if i.status == "active"]
|
||||||
|
|
||||||
|
shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
|
||||||
|
users_map = {u.id: u for u in db.query(User).all()}
|
||||||
|
tables_map = {t.id: t for t in db.query(Table).all()}
|
||||||
|
groups_map = {g.id: g for g in db.query(TableGroup).all()}
|
||||||
|
printers_map = {p.id: p for p in db.query(Printer).all()}
|
||||||
|
|
||||||
|
# ── Per-waiter summary ──────────────────────────────────────────────────
|
||||||
|
waiter_stats: dict = {}
|
||||||
|
|
||||||
|
def _waiter_entry(uid):
|
||||||
|
if uid not in waiter_stats:
|
||||||
|
u = users_map.get(uid)
|
||||||
|
name = (u.full_name or u.nickname or u.username) if u else f"#{uid}"
|
||||||
|
role = u.role if u else "waiter"
|
||||||
|
waiter_stats[uid] = {
|
||||||
|
"waiter_id": uid,
|
||||||
|
"waiter_name": name,
|
||||||
|
"role": role,
|
||||||
|
"orders_opened": 0,
|
||||||
|
"items_ordered": 0,
|
||||||
|
"total_items_value": 0.0,
|
||||||
|
"items_paid": 0,
|
||||||
|
"total_collected": 0.0,
|
||||||
|
"cash": 0.0,
|
||||||
|
"card": 0.0,
|
||||||
|
"transfer": 0.0,
|
||||||
|
"treat": 0.0,
|
||||||
|
"other": 0.0,
|
||||||
|
"shift_started_at": None,
|
||||||
|
"shift_ended_at": None,
|
||||||
|
"starting_cash": None,
|
||||||
|
# Phase 2B payroll
|
||||||
|
"hourly_rate_snapshot": None,
|
||||||
|
"duration_hours": None,
|
||||||
|
"shift_pay": None,
|
||||||
|
# distinct tables served (not order count)
|
||||||
|
"tables_served": 0,
|
||||||
|
}
|
||||||
|
return waiter_stats[uid]
|
||||||
|
|
||||||
|
# track unique tables per waiter (opened_by)
|
||||||
|
waiter_tables: dict = {} # uid → set of table_ids
|
||||||
|
|
||||||
|
for o in orders:
|
||||||
|
if o.status == "cancelled":
|
||||||
|
continue
|
||||||
|
e = _waiter_entry(o.opened_by)
|
||||||
|
e["orders_opened"] += 1
|
||||||
|
if o.table_id:
|
||||||
|
waiter_tables.setdefault(o.opened_by, set()).add(o.table_id)
|
||||||
|
|
||||||
|
for item in all_items:
|
||||||
|
if item.status == "cancelled":
|
||||||
|
continue
|
||||||
|
e = _waiter_entry(item.added_by)
|
||||||
|
e["items_ordered"] += item.quantity
|
||||||
|
e["total_items_value"] += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
for item in paid_items:
|
||||||
|
if item.paid_by:
|
||||||
|
e = _waiter_entry(item.paid_by)
|
||||||
|
val = item.unit_price * item.quantity
|
||||||
|
e["items_paid"] += item.quantity
|
||||||
|
e["total_collected"] += val
|
||||||
|
method = (item.payment_method or "cash").lower()
|
||||||
|
if method == "cash":
|
||||||
|
e["cash"] += val
|
||||||
|
elif method == "card":
|
||||||
|
e["card"] += val
|
||||||
|
elif method == "transfer":
|
||||||
|
e["transfer"] += val
|
||||||
|
elif method in ("treat", "comp"):
|
||||||
|
e["treat"] += val
|
||||||
|
else:
|
||||||
|
e["other"] += val
|
||||||
|
|
||||||
|
from routers.shifts import compute_shift_pay
|
||||||
|
for shift in shifts:
|
||||||
|
e = _waiter_entry(shift.waiter_id)
|
||||||
|
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
|
||||||
|
e["shift_started_at"] = _dt(shift.started_at)
|
||||||
|
if shift.starting_cash is not None:
|
||||||
|
e["starting_cash"] = shift.starting_cash
|
||||||
|
if shift.ended_at:
|
||||||
|
ended = _dt(shift.ended_at)
|
||||||
|
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
|
||||||
|
e["shift_ended_at"] = ended
|
||||||
|
# payroll — take the most recent non-null snapshot
|
||||||
|
if shift.hourly_rate_snapshot is not None:
|
||||||
|
e["hourly_rate_snapshot"] = shift.hourly_rate_snapshot
|
||||||
|
pay_data = compute_shift_pay(shift)
|
||||||
|
e["duration_hours"] = round((e["duration_hours"] or 0) + pay_data["duration_hours"], 2)
|
||||||
|
if pay_data["shift_pay"] is not None:
|
||||||
|
e["shift_pay"] = round((e["shift_pay"] or 0) + pay_data["shift_pay"], 2)
|
||||||
|
|
||||||
|
# write tables_served counts
|
||||||
|
for uid, table_set in waiter_tables.items():
|
||||||
|
if uid in waiter_stats:
|
||||||
|
waiter_stats[uid]["tables_served"] = len(table_set)
|
||||||
|
|
||||||
|
# manager gets shift = day open/close
|
||||||
|
manager_opener = users_map.get(day.opened_by_id)
|
||||||
|
if manager_opener:
|
||||||
|
e = _waiter_entry(day.opened_by_id)
|
||||||
|
if e["shift_started_at"] is None:
|
||||||
|
e["shift_started_at"] = _dt(day.opened_at)
|
||||||
|
if e["shift_ended_at"] is None and day.closed_at:
|
||||||
|
e["shift_ended_at"] = _dt(day.closed_at)
|
||||||
|
|
||||||
|
# ── Per-zone summary ────────────────────────────────────────────────────
|
||||||
|
zone_stats: dict = {} # group_id → stats
|
||||||
|
|
||||||
|
def _zone_entry(gid, gname, gcolor):
|
||||||
|
if gid not in zone_stats:
|
||||||
|
zone_stats[gid] = {
|
||||||
|
"group_id": gid,
|
||||||
|
"group_name": gname,
|
||||||
|
"color": gcolor,
|
||||||
|
"orders": 0,
|
||||||
|
"items": 0,
|
||||||
|
"revenue": 0.0,
|
||||||
|
}
|
||||||
|
return zone_stats[gid]
|
||||||
|
|
||||||
|
for o in orders:
|
||||||
|
if o.status == "cancelled" or not o.table_id:
|
||||||
|
continue
|
||||||
|
table = tables_map.get(o.table_id)
|
||||||
|
group = groups_map.get(table.group_id) if table and table.group_id else None
|
||||||
|
gid = group.id if group else 0
|
||||||
|
gname = group.name if group else "Χωρίς Ζώνη"
|
||||||
|
gcolor = group.color if group else "#9ca3af"
|
||||||
|
entry = _zone_entry(gid, gname, gcolor)
|
||||||
|
entry["orders"] += 1
|
||||||
|
for item in o.items:
|
||||||
|
if item.status == "cancelled":
|
||||||
|
continue
|
||||||
|
entry["items"] += item.quantity
|
||||||
|
if item.status == "paid":
|
||||||
|
entry["revenue"] += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
# ── Per-printer summary ─────────────────────────────────────────────────
|
||||||
|
# Build item_id → value lookup for revenue-per-printer calculation
|
||||||
|
items_value_map = {i.id: i.unit_price * i.quantity for i in all_items if i.status != "cancelled"}
|
||||||
|
|
||||||
|
printer_stats: dict = {}
|
||||||
|
if order_ids:
|
||||||
|
logs = db.query(PrintLog).filter(PrintLog.order_id.in_(order_ids)).all()
|
||||||
|
for log in logs:
|
||||||
|
pid = log.printer_id
|
||||||
|
if pid not in printer_stats:
|
||||||
|
pr = printers_map.get(pid)
|
||||||
|
printer_stats[pid] = {
|
||||||
|
"printer_id": pid,
|
||||||
|
"printer_name": pr.name if pr else f"#{pid}",
|
||||||
|
"ip_address": pr.ip_address if pr else "",
|
||||||
|
"jobs": 0,
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"items_value": 0.0,
|
||||||
|
}
|
||||||
|
printer_stats[pid]["jobs"] += 1
|
||||||
|
if log.success:
|
||||||
|
printer_stats[pid]["success"] += 1
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
item_ids = _json.loads(log.item_ids) if log.item_ids else []
|
||||||
|
for iid in item_ids:
|
||||||
|
printer_stats[pid]["items_value"] += items_value_map.get(iid, 0.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
printer_stats[pid]["failed"] += 1
|
||||||
|
|
||||||
|
for pid in printer_stats:
|
||||||
|
printer_stats[pid]["items_value"] = round(printer_stats[pid]["items_value"], 2)
|
||||||
|
|
||||||
|
# ── Checks ──────────────────────────────────────────────────────────────
|
||||||
|
open_orders = [o for o in orders if o.status in ("open", "partially_paid")]
|
||||||
|
with_unpaid_items = sum(
|
||||||
|
1 for o in open_orders
|
||||||
|
if any(i.status == "active" for i in o.items)
|
||||||
|
)
|
||||||
|
active_shift_count = sum(1 for s in shifts if s.ended_at is None)
|
||||||
|
|
||||||
|
total_revenue = sum(i.unit_price * i.quantity for i in paid_items)
|
||||||
|
total_items_ordered = sum(i.quantity for i in all_items if i.status != "cancelled")
|
||||||
|
total_orders = sum(1 for o in orders if o.status != "cancelled")
|
||||||
|
|
||||||
|
# Round float fields in waiter stats
|
||||||
|
for w in waiter_stats.values():
|
||||||
|
for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"):
|
||||||
|
w[key] = round(w[key], 2)
|
||||||
|
|
||||||
|
# Phase 2E: store-level cash totals for summary view
|
||||||
|
total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts)
|
||||||
|
store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected
|
||||||
|
|
||||||
|
# ── Phase 2K: full financial summary ──────────────<E29480><E29480>─────────────────────
|
||||||
|
|
||||||
|
# COGS from Phase 2A cost snapshots on paid items
|
||||||
|
from models.waste import WasteLog
|
||||||
|
from models.expenses import Expense
|
||||||
|
from models.tabs import Tab, TabEntry
|
||||||
|
|
||||||
|
cogs_trackable = 0.0
|
||||||
|
cogs_uncosted_revenue = 0.0
|
||||||
|
cogs_uncosted_count = 0
|
||||||
|
for item in paid_items:
|
||||||
|
rev = item.unit_price * item.quantity
|
||||||
|
if item.unit_cost is not None:
|
||||||
|
cogs_trackable += item.unit_cost * item.quantity
|
||||||
|
else:
|
||||||
|
cogs_uncosted_revenue += rev
|
||||||
|
cogs_uncosted_count += 1
|
||||||
|
gross_profit = round(total_revenue - cogs_trackable, 2)
|
||||||
|
cogs_has_gap = cogs_uncosted_count > 0
|
||||||
|
|
||||||
|
# LABOR from shifts
|
||||||
|
total_labor_cost = 0.0
|
||||||
|
labor_untracked_shifts = 0
|
||||||
|
for shift in shifts:
|
||||||
|
pay_data = compute_shift_pay(shift)
|
||||||
|
if pay_data["shift_pay"] is not None:
|
||||||
|
total_labor_cost += pay_data["shift_pay"]
|
||||||
|
else:
|
||||||
|
labor_untracked_shifts += 1
|
||||||
|
|
||||||
|
# WASTE from Phase 2H
|
||||||
|
waste_entries = db.query(WasteLog).filter(WasteLog.business_day_id == day.id).all()
|
||||||
|
waste_item_count = len(waste_entries)
|
||||||
|
waste_estimated_cost = round(sum(w.total_cost for w in waste_entries if w.total_cost is not None), 2)
|
||||||
|
|
||||||
|
# EXPENSES logged today
|
||||||
|
expenses_today = db.query(Expense).filter(Expense.business_day_id == day.id).all()
|
||||||
|
expenses_total = round(sum(e.total_amount for e in expenses_today), 2)
|
||||||
|
expenses_paid_today = round(sum(e.paid_amount for e in expenses_today), 2)
|
||||||
|
expenses_due = round(expenses_total - expenses_paid_today, 2)
|
||||||
|
|
||||||
|
# TABBED REVENUE (items moved to tabs — not yet collected)
|
||||||
|
tabbed_items = [i for i in all_items if i.status == "tabbed"]
|
||||||
|
tabbed_revenue = round(sum(i.unit_price * i.quantity for i in tabbed_items), 2)
|
||||||
|
|
||||||
|
# NET estimate (revenue - COGS - labor - waste - expenses paid)
|
||||||
|
net_estimate = round(
|
||||||
|
total_revenue
|
||||||
|
- cogs_trackable
|
||||||
|
- total_labor_cost
|
||||||
|
- waste_estimated_cost
|
||||||
|
- expenses_paid_today,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
net_has_unknowns = cogs_has_gap or labor_untracked_shifts > 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day_id": day.id,
|
||||||
|
"status": day.status,
|
||||||
|
"opened_at": _dt(day.opened_at),
|
||||||
|
"closed_at": _dt(day.closed_at),
|
||||||
|
"total_revenue": round(total_revenue, 2),
|
||||||
|
"total_orders": total_orders,
|
||||||
|
"total_items_ordered": total_items_ordered,
|
||||||
|
"waiters": sorted(waiter_stats.values(), key=lambda x: -x["total_collected"]),
|
||||||
|
"zones": sorted(zone_stats.values(), key=lambda x: -x["revenue"]),
|
||||||
|
"printers": list(printer_stats.values()),
|
||||||
|
"checks": {
|
||||||
|
"open_orders": len(open_orders),
|
||||||
|
"with_unpaid_items": with_unpaid_items,
|
||||||
|
"active_shifts": active_shift_count,
|
||||||
|
},
|
||||||
|
# Phase 2E — cash reconciliation
|
||||||
|
"store_opening_cash": day.store_opening_cash,
|
||||||
|
"store_closing_cash": day.store_closing_cash,
|
||||||
|
"store_cash_discrepancy": day.store_cash_discrepancy,
|
||||||
|
"store_expected_closing": round(store_expected_closing, 2),
|
||||||
|
"total_waiter_collected": round(total_waiter_collected, 2),
|
||||||
|
# Phase 2K — full financial summary
|
||||||
|
"cogs_trackable": round(cogs_trackable, 2),
|
||||||
|
"cogs_uncosted_revenue": round(cogs_uncosted_revenue, 2),
|
||||||
|
"cogs_uncosted_count": cogs_uncosted_count,
|
||||||
|
"cogs_has_gap": cogs_has_gap,
|
||||||
|
"gross_profit": gross_profit,
|
||||||
|
"total_labor_cost": round(total_labor_cost, 2),
|
||||||
|
"labor_untracked_shifts": labor_untracked_shifts,
|
||||||
|
"waste_item_count": waste_item_count,
|
||||||
|
"waste_estimated_cost": waste_estimated_cost,
|
||||||
|
"expenses_total": expenses_total,
|
||||||
|
"expenses_paid_today": expenses_paid_today,
|
||||||
|
"expenses_due": expenses_due,
|
||||||
|
"tabbed_revenue": tabbed_revenue,
|
||||||
|
"net_estimate": net_estimate,
|
||||||
|
"net_has_unknowns": net_has_unknowns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
def business_day_history(
|
def business_day_history(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
176
local_backend/routers/connect_orders.py
Normal file
176
local_backend/routers/connect_orders.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.order import Order
|
||||||
|
from schemas.order import OrderOut
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusUpdateRequest(BaseModel):
|
||||||
|
status: str # "preparing" | "ready" | "out_for_delivery" | "delivered"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_online_order(order_id: int, db: Session) -> Order:
|
||||||
|
order = db.query(Order).filter(Order.id == order_id, Order.source == "online").first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Online order not found")
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_headers() -> dict:
|
||||||
|
return {"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
async def _mirror_status_to_cloud(cloud_order_id: int, new_status: str, rejection_reason: str = None):
|
||||||
|
"""Best-effort push of status update to cloud. Failures are logged, not raised."""
|
||||||
|
if not settings.CLOUD_URL or not cloud_order_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = {"status": new_status}
|
||||||
|
if rejection_reason:
|
||||||
|
body["rejection_reason"] = rejection_reason
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{settings.CLOUD_URL}/api/orders/{cloud_order_id}/status",
|
||||||
|
headers=_cloud_headers(),
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info("Mirrored status %s for cloud order id %d", new_status, cloud_order_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to mirror status to cloud for order id %d: %s", cloud_order_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/orders", response_model=list[OrderOut])
|
||||||
|
def get_orders_by_status(
|
||||||
|
status: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
?status=active → accepted | preparing | ready | out_for_delivery, newest first
|
||||||
|
?status=history → delivered | rejected, newest first, limit 50
|
||||||
|
"""
|
||||||
|
if status == "active":
|
||||||
|
statuses = ["accepted", "preparing", "ready", "out_for_delivery"]
|
||||||
|
orders = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.source == "online", Order.online_status.in_(statuses))
|
||||||
|
.order_by(Order.opened_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
elif status == "history":
|
||||||
|
statuses = ["delivered", "rejected"]
|
||||||
|
orders = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.source == "online", Order.online_status.in_(statuses))
|
||||||
|
.order_by(Order.opened_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown status filter '{status}'. Use 'active' or 'history'.")
|
||||||
|
return orders
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orders/incoming", response_model=list[OrderOut])
|
||||||
|
def get_incoming_orders(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return all online orders pending acceptance, newest first."""
|
||||||
|
orders = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.source == "online", Order.online_status == "pending_acceptance")
|
||||||
|
.order_by(Order.opened_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return orders
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders/{order_id}/accept", response_model=OrderOut)
|
||||||
|
async def accept_order(
|
||||||
|
order_id: int,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
order = _get_online_order(order_id, db)
|
||||||
|
if order.online_status != "pending_acceptance":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
|
||||||
|
|
||||||
|
order.online_status = "accepted"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
|
||||||
|
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "accepted")
|
||||||
|
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "accepted"})
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders/{order_id}/reject", response_model=OrderOut)
|
||||||
|
async def reject_order(
|
||||||
|
order_id: int,
|
||||||
|
body: RejectRequest,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
order = _get_online_order(order_id, db)
|
||||||
|
if order.online_status != "pending_acceptance":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
|
||||||
|
|
||||||
|
order.online_status = "rejected"
|
||||||
|
order.status = "cancelled"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
|
||||||
|
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "rejected", body.reason)
|
||||||
|
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "rejected"})
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orders/{order_id}/status", response_model=OrderOut)
|
||||||
|
async def update_order_status(
|
||||||
|
order_id: int,
|
||||||
|
body: StatusUpdateRequest,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
allowed = {"preparing", "ready", "out_for_delivery", "delivered"}
|
||||||
|
if body.status not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||||
|
|
||||||
|
order = _get_online_order(order_id, db)
|
||||||
|
order.online_status = body.status
|
||||||
|
if body.status == "delivered":
|
||||||
|
order.status = "closed"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
|
||||||
|
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, body.status)
|
||||||
|
broadcast_sync("online_order_updated", {"order_id": order_id, "status": body.status})
|
||||||
|
return order
|
||||||
117
local_backend/routers/customers.py
Normal file
117
local_backend/routers/customers.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.customers import Customer
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.user import User
|
||||||
|
from schemas.customers import CustomerCreate, CustomerUpdate, CustomerOut, AssignCustomerRequest
|
||||||
|
from routers.deps import require_manager, get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_out(c: Customer, db: Session) -> dict:
|
||||||
|
orders = db.query(Order).filter(
|
||||||
|
Order.customer_id == c.id,
|
||||||
|
Order.status.in_(["closed", "paid"]),
|
||||||
|
).all()
|
||||||
|
total_spent = sum(
|
||||||
|
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||||
|
for o in orders
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"nickname": c.nickname,
|
||||||
|
"phone": c.phone,
|
||||||
|
"email": c.email,
|
||||||
|
"notes": c.notes,
|
||||||
|
"is_active": c.is_active,
|
||||||
|
"created_at": c.created_at,
|
||||||
|
"created_by_id": c.created_by_id,
|
||||||
|
"visit_count": len(orders),
|
||||||
|
"total_spent": round(total_spent, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[CustomerOut])
|
||||||
|
def list_customers(
|
||||||
|
search: Optional[str] = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Customer)
|
||||||
|
if not include_inactive:
|
||||||
|
q = q.filter(Customer.is_active == True)
|
||||||
|
if search:
|
||||||
|
term = f"%{search}%"
|
||||||
|
q = q.filter(
|
||||||
|
Customer.name.ilike(term) |
|
||||||
|
Customer.nickname.ilike(term) |
|
||||||
|
Customer.phone.ilike(term)
|
||||||
|
)
|
||||||
|
customers = q.order_by(Customer.name).all()
|
||||||
|
return [_customer_out(c, db) for c in customers]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_customer(body: CustomerCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
customer = Customer(**body.model_dump(), created_by_id=user.id)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return _customer_out(customer, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=CustomerOut)
|
||||||
|
def get_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
return _customer_out(c, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{customer_id}", response_model=CustomerOut)
|
||||||
|
def update_customer(customer_id: int, body: CustomerUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(c, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return _customer_out(c, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
c.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}/orders")
|
||||||
|
def customer_orders(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
c = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
from models.table import Table
|
||||||
|
orders = db.query(Order).filter(Order.customer_id == customer_id).order_by(Order.opened_at.desc()).all()
|
||||||
|
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
result = []
|
||||||
|
for o in orders:
|
||||||
|
total = sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||||
|
result.append({
|
||||||
|
"id": o.id,
|
||||||
|
"table_name": tables_map.get(o.table_id) if o.table_id else None,
|
||||||
|
"opened_at": o.opened_at,
|
||||||
|
"status": o.status,
|
||||||
|
"total": round(total, 2),
|
||||||
|
"item_count": sum(i.quantity for i in o.items if i.status in ("active", "paid")),
|
||||||
|
})
|
||||||
|
return {"orders": result}
|
||||||
220
local_backend/routers/expenses.py
Normal file
220
local_backend/routers/expenses.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.expenses import Contact, Expense, ExpensePayment
|
||||||
|
from models.user import User
|
||||||
|
from schemas.expenses import (
|
||||||
|
ContactCreate, ContactUpdate, ContactOut,
|
||||||
|
ExpenseCreate, ExpenseUpdate, ExpenseOut,
|
||||||
|
ExpensePaymentCreate, ExpensePaymentOut,
|
||||||
|
)
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
contacts_router = APIRouter()
|
||||||
|
expenses_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _expense_status(total: float, paid: float) -> str:
|
||||||
|
if paid >= total:
|
||||||
|
return "paid"
|
||||||
|
if paid > 0:
|
||||||
|
return "partial"
|
||||||
|
return "due"
|
||||||
|
|
||||||
|
|
||||||
|
def _expense_out(e: Expense) -> dict:
|
||||||
|
contact_name = None
|
||||||
|
if e.contact:
|
||||||
|
contact_name = e.contact.name
|
||||||
|
creator_name = None
|
||||||
|
if e.created_by:
|
||||||
|
creator_name = e.created_by.full_name or e.created_by.username
|
||||||
|
due_amount = round(max(0.0, e.total_amount - e.paid_amount), 2)
|
||||||
|
payments_out = []
|
||||||
|
for p in e.payments:
|
||||||
|
payer_name = None
|
||||||
|
if p.paid_by:
|
||||||
|
payer_name = p.paid_by.full_name or p.paid_by.username
|
||||||
|
payments_out.append({
|
||||||
|
"id": p.id,
|
||||||
|
"expense_id": p.expense_id,
|
||||||
|
"amount": p.amount,
|
||||||
|
"paid_at": p.paid_at,
|
||||||
|
"paid_by_id": p.paid_by_id,
|
||||||
|
"paid_by_name": payer_name,
|
||||||
|
"notes": p.notes,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"id": e.id,
|
||||||
|
"description": e.description,
|
||||||
|
"category": e.category,
|
||||||
|
"contact_id": e.contact_id,
|
||||||
|
"contact_name": contact_name,
|
||||||
|
"total_amount": e.total_amount,
|
||||||
|
"paid_amount": e.paid_amount,
|
||||||
|
"due_amount": due_amount,
|
||||||
|
"status": _expense_status(e.total_amount, e.paid_amount),
|
||||||
|
"due_date": e.due_date,
|
||||||
|
"business_day_id": e.business_day_id,
|
||||||
|
"created_by_id": e.created_by_id,
|
||||||
|
"created_by_name": creator_name,
|
||||||
|
"created_at": e.created_at,
|
||||||
|
"notes": e.notes,
|
||||||
|
"payments": payments_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contacts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@contacts_router.get("/", response_model=List[ContactOut])
|
||||||
|
def list_contacts(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Contact)
|
||||||
|
if not include_inactive:
|
||||||
|
q = q.filter(Contact.is_active == True)
|
||||||
|
return q.order_by(Contact.name).all()
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_router.post("/", response_model=ContactOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_contact(body: ContactCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
contact = Contact(**body.model_dump())
|
||||||
|
db.add(contact)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_router.put("/{contact_id}", response_model=ContactOut)
|
||||||
|
def update_contact(contact_id: int, body: ContactUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(contact, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@contacts_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_contact(contact_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
contact.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expenses ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@expenses_router.get("/summary")
|
||||||
|
def expense_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
expenses = db.query(Expense).all()
|
||||||
|
total_due = sum(max(0.0, e.total_amount - e.paid_amount) for e in expenses if e.paid_amount < e.total_amount)
|
||||||
|
total_paid = sum(e.paid_amount for e in expenses)
|
||||||
|
by_category: dict = {}
|
||||||
|
for e in expenses:
|
||||||
|
c = e.category
|
||||||
|
if c not in by_category:
|
||||||
|
by_category[c] = {"category": c, "total": 0.0, "paid": 0.0, "due": 0.0}
|
||||||
|
by_category[c]["total"] += e.total_amount
|
||||||
|
by_category[c]["paid"] += e.paid_amount
|
||||||
|
by_category[c]["due"] += max(0.0, e.total_amount - e.paid_amount)
|
||||||
|
return {
|
||||||
|
"total_due": round(total_due, 2),
|
||||||
|
"total_paid": round(total_paid, 2),
|
||||||
|
"by_category": list(by_category.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@expenses_router.get("/", response_model=List[ExpenseOut])
|
||||||
|
def list_expenses(
|
||||||
|
expense_status: Optional[str] = Query(default=None, alias="status"),
|
||||||
|
category: Optional[str] = None,
|
||||||
|
contact_id: Optional[int] = None,
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
expenses = db.query(Expense).order_by(Expense.created_at.desc()).all()
|
||||||
|
result = [_expense_out(e) for e in expenses]
|
||||||
|
if expense_status:
|
||||||
|
result = [e for e in result if e["status"] == expense_status]
|
||||||
|
if category:
|
||||||
|
result = [e for e in result if e["category"] == category]
|
||||||
|
if contact_id:
|
||||||
|
result = [e for e in result if e["contact_id"] == contact_id]
|
||||||
|
if business_day_id:
|
||||||
|
result = [e for e in result if e["business_day_id"] == business_day_id]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@expenses_router.post("/", response_model=ExpenseOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_expense(body: ExpenseCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
expense = Expense(**body.model_dump(), created_by_id=user.id)
|
||||||
|
db.add(expense)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(expense)
|
||||||
|
return _expense_out(expense)
|
||||||
|
|
||||||
|
|
||||||
|
@expenses_router.put("/{expense_id}", response_model=ExpenseOut)
|
||||||
|
def update_expense(expense_id: int, body: ExpenseUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||||
|
if not expense:
|
||||||
|
raise HTTPException(status_code=404, detail="Expense not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(expense, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(expense)
|
||||||
|
return _expense_out(expense)
|
||||||
|
|
||||||
|
|
||||||
|
@expenses_router.delete("/{expense_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_expense(expense_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||||
|
if not expense:
|
||||||
|
raise HTTPException(status_code=404, detail="Expense not found")
|
||||||
|
db.delete(expense)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@expenses_router.post("/{expense_id}/pay", response_model=ExpenseOut)
|
||||||
|
def record_payment(
|
||||||
|
expense_id: int,
|
||||||
|
body: ExpensePaymentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
expense = db.query(Expense).filter(Expense.id == expense_id).first()
|
||||||
|
if not expense:
|
||||||
|
raise HTTPException(status_code=404, detail="Expense not found")
|
||||||
|
if body.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Payment amount must be positive")
|
||||||
|
payment = ExpensePayment(
|
||||||
|
expense_id=expense_id,
|
||||||
|
amount=body.amount,
|
||||||
|
paid_by_id=user.id,
|
||||||
|
notes=body.notes,
|
||||||
|
)
|
||||||
|
db.add(payment)
|
||||||
|
db.flush()
|
||||||
|
# recompute paid_amount from all payments (never trust client totals)
|
||||||
|
all_payments = db.query(ExpensePayment).filter(ExpensePayment.expense_id == expense_id).all()
|
||||||
|
expense.paid_amount = round(sum(p.amount for p in all_payments), 2)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(expense)
|
||||||
|
return _expense_out(expense)
|
||||||
128
local_backend/routers/kds.py
Normal file
128
local_backend/routers/kds.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
KDS — Kitchen Display System router.
|
||||||
|
|
||||||
|
GET /api/kds/items
|
||||||
|
Returns all active (status='active') order items for open orders,
|
||||||
|
enriched with zone name and table name. Used by the KDS frontend.
|
||||||
|
|
||||||
|
PUT /api/orders/{order_id}/items/{item_id}/status
|
||||||
|
Mark an item ready (active → ready). Only that transition is allowed here.
|
||||||
|
Broadcasts item_status_changed SSE event.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.product import Product
|
||||||
|
from models.printer import Printer
|
||||||
|
from models.table import Table
|
||||||
|
from models.user import User
|
||||||
|
from routers.deps import get_current_user
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemStatusUpdate(BaseModel):
|
||||||
|
status: str # only "ready" is accepted
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items")
|
||||||
|
def kds_items(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return all active order items grouped by zone for the KDS display."""
|
||||||
|
# Only look at open / partially_paid orders
|
||||||
|
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
|
||||||
|
order_ids = [o.id for o in open_orders]
|
||||||
|
if not order_ids:
|
||||||
|
return {"zones": []}
|
||||||
|
|
||||||
|
order_map = {o.id: o for o in open_orders}
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id.in_(order_ids),
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).order_by(OrderItem.added_at.asc()).all()
|
||||||
|
|
||||||
|
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
printers_map = {p.id: p.name for p in db.query(Printer).all()}
|
||||||
|
|
||||||
|
# Zone = printer_zone_id (None = no zone)
|
||||||
|
zones: dict = {}
|
||||||
|
|
||||||
|
def _zone_key(zone_id):
|
||||||
|
if zone_id is None:
|
||||||
|
return 0
|
||||||
|
return zone_id
|
||||||
|
|
||||||
|
def _zone_name(zone_id):
|
||||||
|
if zone_id is None:
|
||||||
|
return "Χωρίς Ζώνη"
|
||||||
|
return printers_map.get(zone_id, f"Ζώνη #{zone_id}")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
product = item.product
|
||||||
|
zone_id = product.printer_zone_id if product else None
|
||||||
|
zkey = _zone_key(zone_id)
|
||||||
|
|
||||||
|
if zkey not in zones:
|
||||||
|
zones[zkey] = {
|
||||||
|
"zone_id": zone_id,
|
||||||
|
"zone_name": _zone_name(zone_id),
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
order = order_map.get(item.order_id)
|
||||||
|
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||||
|
|
||||||
|
zones[zkey]["items"].append({
|
||||||
|
"id": item.id,
|
||||||
|
"order_id": item.order_id,
|
||||||
|
"table_name": table_name,
|
||||||
|
"product_name": product.name if product else f"#{item.product_id}",
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"notes": item.notes,
|
||||||
|
"added_at": item.added_at,
|
||||||
|
"status": item.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort zones: named zones first (by zone_id), then no-zone last
|
||||||
|
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
|
||||||
|
return {"zones": zone_list}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/orders/{order_id}/items/{item_id}/status")
|
||||||
|
def update_item_status(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
body: ItemStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if body.status != "ready":
|
||||||
|
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
|
||||||
|
|
||||||
|
item = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id == item_id,
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if item.status != "active":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Item is '{item.status}' — only active items can be marked ready")
|
||||||
|
|
||||||
|
item.status = "ready"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
broadcast_sync("item_status_changed", {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"status": "ready",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "ready", "item_id": item_id}
|
||||||
151
local_backend/routers/notes.py
Normal file
151
local_backend/routers/notes.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.notes import SiteNote, SiteTodo
|
||||||
|
from models.user import User
|
||||||
|
from schemas.notes import NoteCreate, NoteUpdate, NoteOut, TodoCreate, TodoUpdate, TodoOut
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _note_out(n: SiteNote) -> dict:
|
||||||
|
name = None
|
||||||
|
if n.created_by:
|
||||||
|
name = n.created_by.full_name or n.created_by.username
|
||||||
|
return {
|
||||||
|
"id": n.id,
|
||||||
|
"body": n.body,
|
||||||
|
"is_pinned": n.is_pinned,
|
||||||
|
"created_by_id": n.created_by_id,
|
||||||
|
"created_by_name": name,
|
||||||
|
"created_at": n.created_at,
|
||||||
|
"updated_at": n.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _todo_out(t: SiteTodo) -> dict:
|
||||||
|
creator_name = None
|
||||||
|
if t.created_by:
|
||||||
|
creator_name = t.created_by.full_name or t.created_by.username
|
||||||
|
done_name = None
|
||||||
|
if t.done_by:
|
||||||
|
done_name = t.done_by.full_name or t.done_by.username
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"body": t.body,
|
||||||
|
"is_done": t.is_done,
|
||||||
|
"done_at": t.done_at,
|
||||||
|
"done_by_id": t.done_by_id,
|
||||||
|
"done_by_name": done_name,
|
||||||
|
"created_by_id": t.created_by_id,
|
||||||
|
"created_by_name": creator_name,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"priority": t.priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[NoteOut])
|
||||||
|
def list_notes(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
notes = db.query(SiteNote).order_by(
|
||||||
|
SiteNote.is_pinned.desc(), SiteNote.updated_at.desc()
|
||||||
|
).all()
|
||||||
|
return [_note_out(n) for n in notes]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=NoteOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_note(body: NoteCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = SiteNote(body=body.body, is_pinned=body.is_pinned, created_by_id=user.id)
|
||||||
|
db.add(note)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{note_id}", response_model=NoteOut)
|
||||||
|
def update_note(note_id: int, body: NoteUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
if body.body is not None:
|
||||||
|
note.body = body.body
|
||||||
|
if body.is_pinned is not None:
|
||||||
|
note.is_pinned = body.is_pinned
|
||||||
|
note.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_out(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_note(note_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
db.delete(note)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Todos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/todos", response_model=List[TodoOut])
|
||||||
|
def list_todos(done: bool = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
q = db.query(SiteTodo)
|
||||||
|
if done is not None:
|
||||||
|
q = q.filter(SiteTodo.is_done == done)
|
||||||
|
# undone high-priority first, then undone normal, then done (by created_at desc)
|
||||||
|
todos = q.order_by(
|
||||||
|
SiteTodo.is_done.asc(),
|
||||||
|
SiteTodo.priority.desc(),
|
||||||
|
SiteTodo.created_at.desc(),
|
||||||
|
).all()
|
||||||
|
return [_todo_out(t) for t in todos]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/todos", response_model=TodoOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_todo(body: TodoCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = SiteTodo(body=body.body, priority=body.priority, created_by_id=user.id)
|
||||||
|
db.add(todo)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return _todo_out(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/todos/{todo_id}", response_model=TodoOut)
|
||||||
|
def update_todo(todo_id: int, body: TodoUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
if body.body is not None:
|
||||||
|
todo.body = body.body
|
||||||
|
if body.priority is not None:
|
||||||
|
todo.priority = body.priority
|
||||||
|
if body.is_done is not None:
|
||||||
|
todo.is_done = body.is_done
|
||||||
|
if body.is_done and not todo.done_at:
|
||||||
|
todo.done_at = _utcnow()
|
||||||
|
todo.done_by_id = user.id
|
||||||
|
elif not body.is_done:
|
||||||
|
todo.done_at = None
|
||||||
|
todo.done_by_id = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
return _todo_out(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_todo(todo_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
db.delete(todo)
|
||||||
|
db.commit()
|
||||||
@@ -67,16 +67,21 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
|
||||||
|
|
||||||
@router.get("/", response_model=List[OrderOut])
|
@router.get("/", response_model=List[OrderOut])
|
||||||
def list_orders(
|
def list_orders(
|
||||||
order_status: Optional[str] = None,
|
order_status: Optional[str] = None,
|
||||||
waiter_id: Optional[int] = None,
|
waiter_id: Optional[int] = None,
|
||||||
|
all: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
q = db.query(Order)
|
q = db.query(Order)
|
||||||
if order_status:
|
if order_status:
|
||||||
q = q.filter(Order.status == order_status)
|
q = q.filter(Order.status == order_status)
|
||||||
|
elif not all:
|
||||||
|
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
|
||||||
if waiter_id:
|
if waiter_id:
|
||||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
return q.all()
|
return q.all()
|
||||||
@@ -183,9 +188,22 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
|||||||
"is_duplicate": l.is_duplicate,
|
"is_duplicate": l.is_duplicate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
|
||||||
|
table_name = (table.label or f"T{table.number}") if table else None
|
||||||
|
|
||||||
|
# Phase 2F: resolve customer name for display
|
||||||
|
customer_name = None
|
||||||
|
customer_phone = None
|
||||||
|
if order.customer_id and order.customer:
|
||||||
|
customer_name = order.customer.name
|
||||||
|
if order.customer.nickname:
|
||||||
|
customer_name = f"{order.customer.name} «{order.customer.nickname}»"
|
||||||
|
customer_phone = order.customer.phone
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": order.id,
|
"id": order.id,
|
||||||
"table_id": order.table_id,
|
"table_id": order.table_id,
|
||||||
|
"table_name": table_name,
|
||||||
"opened_by": order.opened_by,
|
"opened_by": order.opened_by,
|
||||||
"opened_at": order.opened_at,
|
"opened_at": order.opened_at,
|
||||||
"status": order.status,
|
"status": order.status,
|
||||||
@@ -193,6 +211,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
|||||||
"closed_by": order.closed_by,
|
"closed_by": order.closed_by,
|
||||||
"notes": order.notes,
|
"notes": order.notes,
|
||||||
"business_day_id": order.business_day_id,
|
"business_day_id": order.business_day_id,
|
||||||
|
"customer_id": order.customer_id,
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"customer_phone": customer_phone,
|
||||||
"items": [fmt_item(i) for i in order.items],
|
"items": [fmt_item(i) for i in order.items],
|
||||||
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
|
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
|
||||||
"audit_logs": [fmt_log(l) for l in order.audit_logs],
|
"audit_logs": [fmt_log(l) for l in order.audit_logs],
|
||||||
@@ -261,12 +282,25 @@ def add_items(
|
|||||||
(o.price_delta or o.extra_cost or 0.0)
|
(o.price_delta or o.extra_cost or 0.0)
|
||||||
for o in (item_in.selected_options or [])
|
for o in (item_in.selected_options or [])
|
||||||
)
|
)
|
||||||
|
# Phase 2A: cost snapshot — breakdown takes priority over simple cost
|
||||||
|
unit_cost = None
|
||||||
|
if product.cost_breakdown:
|
||||||
|
try:
|
||||||
|
entries = json.loads(product.cost_breakdown)
|
||||||
|
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
|
||||||
|
if total > 0:
|
||||||
|
unit_cost = total
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if unit_cost is None and product.cost_simple:
|
||||||
|
unit_cost = product.cost_simple
|
||||||
item = OrderItem(
|
item = OrderItem(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
product_id=item_in.product_id,
|
product_id=item_in.product_id,
|
||||||
added_by=user.id,
|
added_by=user.id,
|
||||||
quantity=item_in.quantity,
|
quantity=item_in.quantity,
|
||||||
unit_price=product.base_price + extra_cost,
|
unit_price=product.base_price + extra_cost,
|
||||||
|
unit_cost=unit_cost,
|
||||||
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
||||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||||
notes=item_in.notes,
|
notes=item_in.notes,
|
||||||
@@ -340,25 +374,26 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
items = db.query(OrderItem).filter(
|
items = db.query(OrderItem).filter(
|
||||||
OrderItem.id.in_(body.item_ids),
|
OrderItem.id.in_(body.item_ids),
|
||||||
OrderItem.order_id == order_id,
|
OrderItem.order_id == order_id,
|
||||||
OrderItem.status == "active",
|
OrderItem.status.in_(["active", "ready"]), # ready items are also payable
|
||||||
).all()
|
).all()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
active_shift = db.query(WaiterShift).filter(
|
active_shift = db.query(WaiterShift).filter(
|
||||||
WaiterShift.waiter_id == user.id,
|
WaiterShift.waiter_id == user.id,
|
||||||
WaiterShift.ended_at == None,
|
WaiterShift.ended_at == None,
|
||||||
).first()
|
).first()
|
||||||
|
effective_method = body.payment_method or "cash"
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
for item in items:
|
for item in items:
|
||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = now
|
item.paid_at = now
|
||||||
item.payment_method = body.payment_method
|
item.payment_method = effective_method
|
||||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
total_paid += item.unit_price * item.quantity
|
total_paid += item.unit_price * item.quantity
|
||||||
|
|
||||||
db.flush() # write item status changes before counting, since autoflush=False
|
db.flush() # write item status changes before counting, since autoflush=False
|
||||||
active_remaining = db.query(OrderItem).filter(
|
active_remaining = db.query(OrderItem).filter(
|
||||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"])
|
||||||
).count()
|
).count()
|
||||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||||
|
|
||||||
@@ -452,6 +487,7 @@ def pay_items_offline(
|
|||||||
WaiterShift.ended_at == None,
|
WaiterShift.ended_at == None,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
effective_method = body.payment_method or "cash"
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
paid_ids = []
|
paid_ids = []
|
||||||
if not is_duplicate:
|
if not is_duplicate:
|
||||||
@@ -459,7 +495,7 @@ def pay_items_offline(
|
|||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = paid_at
|
item.paid_at = paid_at
|
||||||
item.payment_method = body.payment_method
|
item.payment_method = effective_method
|
||||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
total_paid += item.unit_price * item.quantity
|
total_paid += item.unit_price * item.quantity
|
||||||
paid_ids.append(item.id)
|
paid_ids.append(item.id)
|
||||||
@@ -543,6 +579,30 @@ def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depend
|
|||||||
return {"status": "assigned"}
|
return {"status": "assigned"}
|
||||||
|
|
||||||
|
|
||||||
|
class AssignCustomerBody(BaseModel):
|
||||||
|
customer_id: Optional[int] = None # null = unassign
|
||||||
|
|
||||||
|
|
||||||
|
class TabItemBody(BaseModel):
|
||||||
|
pass # no body needed — tab derived from order's customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{order_id}/customer")
|
||||||
|
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
from models.customers import Customer
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if body.customer_id is not None:
|
||||||
|
customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first()
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
order.customer_id = body.customer_id
|
||||||
|
db.commit()
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"})
|
||||||
|
return {"status": "ok", "customer_id": order.customer_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
assignment = db.query(OrderWaiter).filter(
|
assignment = db.query(OrderWaiter).filter(
|
||||||
@@ -605,7 +665,7 @@ def print_order(
|
|||||||
"notes": order.notes,
|
"notes": order.notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width)
|
||||||
return {"status": "printing"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
@@ -900,5 +960,57 @@ def print_synopsis(
|
|||||||
"remaining": total - paid_total,
|
"remaining": total - paid_total,
|
||||||
}
|
}
|
||||||
|
|
||||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
|
||||||
return {"status": "printing"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Phase 2G: Put item on customer tab ───────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/items/{item_id}/tab")
|
||||||
|
def put_item_on_tab(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.tabs import Tab, TabEntry
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not order.customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first")
|
||||||
|
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if item.status != "active":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Item is already {item.status} — cannot tab it")
|
||||||
|
|
||||||
|
# Find or auto-create the customer's open tab
|
||||||
|
tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first()
|
||||||
|
if not tab:
|
||||||
|
tab = Tab(customer_id=order.customer_id)
|
||||||
|
db.add(tab)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Build description
|
||||||
|
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||||
|
description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}"
|
||||||
|
|
||||||
|
entry = TabEntry(
|
||||||
|
tab_id=tab.id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_item_id=item_id,
|
||||||
|
amount=round(item.unit_price * item.quantity, 2),
|
||||||
|
description=description,
|
||||||
|
created_by_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
|
||||||
|
# Mark item as tabbed — distinct from active/paid/cancelled
|
||||||
|
item.status = "tabbed"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"})
|
||||||
|
return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount}
|
||||||
|
|||||||
@@ -235,9 +235,11 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
|||||||
|
|
||||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
|
||||||
if data.get("sort_order") == 0:
|
if data.get("sort_order") == 0:
|
||||||
data["sort_order"] = db.query(Product).count()
|
data["sort_order"] = db.query(Product).count()
|
||||||
|
if body.cost_breakdown is not None:
|
||||||
|
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||||
product = Product(**data)
|
product = Product(**data)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -277,8 +279,18 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
|||||||
product = db.query(Product).filter(Product.id == product_id).first()
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
scalar_fields = body.model_dump(
|
||||||
|
exclude_none=True,
|
||||||
|
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
|
||||||
|
)
|
||||||
|
for field, value in scalar_fields.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
# cost_breakdown is a list of objects — serialize to JSON for storage
|
||||||
|
if body.cost_breakdown is not None:
|
||||||
|
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||||
|
elif "cost_breakdown" in body.model_fields_set:
|
||||||
|
# explicitly set to null — clear it
|
||||||
|
product.cost_breakdown = None
|
||||||
if body.quick_options is not None:
|
if body.quick_options is not None:
|
||||||
_replace_quick_options(db, product, body.quick_options)
|
_replace_quick_options(db, product, body.quick_options)
|
||||||
if body.options is not None:
|
if body.options is not None:
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ from models.business_day import BusinessDay
|
|||||||
from schemas.order import OrderOut
|
from schemas.order import OrderOut
|
||||||
from schemas.table import TableOut
|
from schemas.table import TableOut
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager
|
||||||
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
|
from services.printer_service import (
|
||||||
|
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
|
||||||
|
print_products_report, print_categories_report, print_tables_report,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -416,17 +419,88 @@ class PrintWaiterReportBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrintPrinterReportBody(BaseModel):
|
class PrintPrinterReportBody(BaseModel):
|
||||||
|
# printer_target_id: 0 = all printers, >0 = specific printer id
|
||||||
printer_target_id: int
|
printer_target_id: int
|
||||||
printer_id: int
|
printer_id: int
|
||||||
mode: str # "simple" | "extensive"
|
# mode: "quick" | "orders" | "detailed"
|
||||||
from_dt: str
|
mode: str
|
||||||
to_dt: str
|
from_dt: Optional[str] = None
|
||||||
|
to_dt: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
item_breakdown: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PrintOrderBody(BaseModel):
|
class PrintOrderBody(BaseModel):
|
||||||
printer_id: int
|
printer_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, mode: str) -> dict:
|
||||||
|
"""
|
||||||
|
Build a report block for a single printer from its logs.
|
||||||
|
Returns dict with print_jobs, orders, items, total, order_data, item_breakdown.
|
||||||
|
item_breakdown is always computed (used for product analysis section when requested).
|
||||||
|
"""
|
||||||
|
order_map: dict = {}
|
||||||
|
item_breakdown: dict = {} # name → {"qty": int, "value": float}
|
||||||
|
items_count = 0
|
||||||
|
grand_total = 0.0
|
||||||
|
|
||||||
|
for log in logs:
|
||||||
|
oid = log.order_id
|
||||||
|
if oid not in order_map:
|
||||||
|
order = db.query(Order).filter(Order.id == oid).first()
|
||||||
|
if order:
|
||||||
|
order_map[oid] = {
|
||||||
|
"id": oid,
|
||||||
|
"time": log.printed_at.strftime("%H:%M"),
|
||||||
|
"table": tables.get(order.table_id, f"#{order.table_id}"),
|
||||||
|
"total": 0.0,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
item_ids = json.loads(log.item_ids)
|
||||||
|
except Exception:
|
||||||
|
item_ids = []
|
||||||
|
for item_id in item_ids:
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||||
|
if item and item.status in ("active", "paid"):
|
||||||
|
items_count += item.quantity
|
||||||
|
val = item.unit_price * item.quantity
|
||||||
|
grand_total += val
|
||||||
|
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||||
|
if oid in order_map:
|
||||||
|
order_map[oid]["total"] += val
|
||||||
|
order_map[oid]["items"].append({
|
||||||
|
"name": product_name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": float(item.unit_price),
|
||||||
|
"total": round(val, 2),
|
||||||
|
})
|
||||||
|
# Accumulate item breakdown (always, regardless of mode)
|
||||||
|
if product_name not in item_breakdown:
|
||||||
|
item_breakdown[product_name] = {"qty": 0, "value": 0.0}
|
||||||
|
item_breakdown[product_name]["qty"] += item.quantity
|
||||||
|
item_breakdown[product_name]["value"] = round(item_breakdown[product_name]["value"] + val, 2)
|
||||||
|
|
||||||
|
# Convert breakdown to sorted list for easy rendering
|
||||||
|
breakdown_list = sorted(
|
||||||
|
[{"name": k, "qty": v["qty"], "value": v["value"]} for k, v in item_breakdown.items()],
|
||||||
|
key=lambda x: x["qty"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"printer_id": printer_id,
|
||||||
|
"printer_name": printer_name,
|
||||||
|
"print_jobs": len(logs),
|
||||||
|
"orders": len(order_map),
|
||||||
|
"items": items_count,
|
||||||
|
"total": round(grand_total, 2),
|
||||||
|
"order_data": list(order_map.values()) if mode in ("orders", "detailed") else [],
|
||||||
|
"item_breakdown": breakdown_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/print/waiter")
|
@router.post("/print/waiter")
|
||||||
def print_waiter(
|
def print_waiter(
|
||||||
body: PrintWaiterReportBody,
|
body: PrintWaiterReportBody,
|
||||||
@@ -497,73 +571,293 @@ def print_printer_totals(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
|
# The physical printer that will receive the paper
|
||||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
if not printer:
|
if not printer:
|
||||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||||
|
|
||||||
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
|
|
||||||
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
|
|
||||||
|
|
||||||
from_dt = datetime.fromisoformat(body.from_dt)
|
|
||||||
to_dt = datetime.fromisoformat(body.to_dt)
|
|
||||||
|
|
||||||
logs = db.query(PrintLog).filter(
|
|
||||||
PrintLog.printer_id == body.printer_target_id,
|
|
||||||
PrintLog.success == True,
|
|
||||||
PrintLog.printed_at >= from_dt,
|
|
||||||
PrintLog.printed_at <= to_dt,
|
|
||||||
).all()
|
|
||||||
|
|
||||||
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
|
||||||
# Build per-order entries keyed by order_id; each log may add more items
|
# Build time filter for logs
|
||||||
order_map: dict = {}
|
def _base_log_q():
|
||||||
items_count = 0
|
q = db.query(PrintLog).filter(PrintLog.success == True)
|
||||||
grand_total = 0.0
|
if body.business_day_id:
|
||||||
for log in logs:
|
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
|
||||||
oid = log.order_id
|
elif body.from_dt and body.to_dt:
|
||||||
if oid not in order_map:
|
from_dt = datetime.fromisoformat(body.from_dt)
|
||||||
order = db.query(Order).filter(Order.id == oid).first()
|
to_dt = datetime.fromisoformat(body.to_dt)
|
||||||
if order:
|
q = q.filter(PrintLog.printed_at >= from_dt, PrintLog.printed_at <= to_dt)
|
||||||
order_map[oid] = {
|
return q
|
||||||
"id": oid,
|
|
||||||
"time": log.printed_at.strftime("%H:%M"),
|
|
||||||
"table": tables.get(order.table_id, f"#{order.table_id}"),
|
|
||||||
"total": 0.0,
|
|
||||||
"items": [],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
item_ids = json.loads(log.item_ids)
|
|
||||||
except Exception:
|
|
||||||
item_ids = []
|
|
||||||
for item_id in item_ids:
|
|
||||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
|
||||||
if item and item.status in ("active", "paid"):
|
|
||||||
items_count += item.quantity
|
|
||||||
val = item.unit_price * item.quantity
|
|
||||||
grand_total += val
|
|
||||||
if oid in order_map:
|
|
||||||
order_map[oid]["total"] += val
|
|
||||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
|
||||||
order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity})
|
|
||||||
|
|
||||||
order_data = list(order_map.values())
|
# Determine period label for the receipt header
|
||||||
|
if body.business_day_id:
|
||||||
|
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
||||||
|
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
|
||||||
|
period_is_workday = True
|
||||||
|
else:
|
||||||
|
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
|
||||||
|
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
|
||||||
|
period_label = (
|
||||||
|
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
|
||||||
|
if from_dt and to_dt else "—"
|
||||||
|
)
|
||||||
|
period_is_workday = False
|
||||||
|
|
||||||
|
if body.printer_target_id == 0:
|
||||||
|
# All printers — build one block per active printer that has logs
|
||||||
|
all_printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||||
|
printer_blocks = []
|
||||||
|
for p in all_printers:
|
||||||
|
logs = _base_log_q().filter(PrintLog.printer_id == p.id).all()
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
block = _build_printer_block(p.id, p.name, logs, tables, db, body.mode)
|
||||||
|
printer_blocks.append(block)
|
||||||
|
else:
|
||||||
|
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
|
||||||
|
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
|
||||||
|
logs = _base_log_q().filter(PrintLog.printer_id == body.printer_target_id).all()
|
||||||
|
printer_blocks = [_build_printer_block(body.printer_target_id, target_name, logs, tables, db, body.mode)]
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"printer_name": target_name,
|
"printer_blocks": printer_blocks,
|
||||||
"print_jobs": len(logs),
|
"period_label": period_label,
|
||||||
"orders": len(order_map),
|
"period_is_workday": period_is_workday,
|
||||||
"items": items_count,
|
"mode": body.mode,
|
||||||
"total": grand_total,
|
"item_breakdown": body.item_breakdown,
|
||||||
"order_data": order_data if body.mode == "extensive" else [],
|
"line_width": printer.line_width,
|
||||||
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
|
"div_style": load_divider_style(db),
|
||||||
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||||
return {"status": "printing"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Print: products / categories / tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PrintAnalyticsBody(BaseModel):
|
||||||
|
printer_id: int
|
||||||
|
mode: str # "smart" | "full"
|
||||||
|
from_dt: Optional[str] = None
|
||||||
|
to_dt: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]:
|
||||||
|
"""Return (period_label, period_is_workday)."""
|
||||||
|
if body.business_day_id:
|
||||||
|
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
||||||
|
label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
|
||||||
|
return label, True
|
||||||
|
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
|
||||||
|
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
|
||||||
|
label = (
|
||||||
|
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
|
||||||
|
if from_dt and to_dt else "—"
|
||||||
|
)
|
||||||
|
return label, False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/print/products")
|
||||||
|
def print_products(
|
||||||
|
body: PrintAnalyticsBody,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.product import Product, Category
|
||||||
|
|
||||||
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||||
|
|
||||||
|
period_label, period_is_workday = _analytics_period(body, db)
|
||||||
|
|
||||||
|
# Build sold-item summary
|
||||||
|
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||||
|
if body.business_day_id:
|
||||||
|
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
|
||||||
|
elif body.from_dt and body.to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
|
||||||
|
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
|
||||||
|
)
|
||||||
|
items = q.all()
|
||||||
|
|
||||||
|
sold: dict = {}
|
||||||
|
for item in items:
|
||||||
|
pid = item.product_id
|
||||||
|
if pid not in sold:
|
||||||
|
sold[pid] = {"qty": 0, "revenue": 0.0}
|
||||||
|
sold[pid]["qty"] += item.quantity
|
||||||
|
sold[pid]["revenue"] += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
if body.mode == "full":
|
||||||
|
# All active products, 0-sold included
|
||||||
|
all_products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
|
||||||
|
report_items = sorted(
|
||||||
|
[{"name": p.name, "qty": sold.get(p.id, {}).get("qty", 0), "revenue": round(sold.get(p.id, {}).get("revenue", 0.0), 2)} for p in all_products],
|
||||||
|
key=lambda x: x["qty"], reverse=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Smart: only sold
|
||||||
|
products_db = {p.id: p for p in db.query(Product).all()}
|
||||||
|
report_items = sorted(
|
||||||
|
[{"name": (products_db[pid].name if pid in products_db else f"#{pid}"), "qty": v["qty"], "revenue": round(v["revenue"], 2)} for pid, v in sold.items()],
|
||||||
|
key=lambda x: x["qty"], reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"mode": body.mode,
|
||||||
|
"period_label": period_label,
|
||||||
|
"period_is_workday": period_is_workday,
|
||||||
|
"items": report_items,
|
||||||
|
"line_width": printer.line_width,
|
||||||
|
"div_style": load_divider_style(db),
|
||||||
|
}
|
||||||
|
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
|
||||||
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/print/categories")
|
||||||
|
def print_categories(
|
||||||
|
body: PrintAnalyticsBody,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.product import Product, Category
|
||||||
|
|
||||||
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||||
|
|
||||||
|
period_label, period_is_workday = _analytics_period(body, db)
|
||||||
|
|
||||||
|
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||||
|
if body.business_day_id:
|
||||||
|
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
|
||||||
|
elif body.from_dt and body.to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
|
||||||
|
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
|
||||||
|
)
|
||||||
|
items = q.all()
|
||||||
|
|
||||||
|
products_db = {p.id: p for p in db.query(Product).all()}
|
||||||
|
categories_db = {c.id: c for c in db.query(Category).all()}
|
||||||
|
# summary[cid] = {name, units_sold, revenue, products: {pid: {name, qty, revenue}}}
|
||||||
|
summary: dict = {}
|
||||||
|
for item in items:
|
||||||
|
product = products_db.get(item.product_id)
|
||||||
|
if not product:
|
||||||
|
continue
|
||||||
|
cid = product.category_id
|
||||||
|
pid = item.product_id
|
||||||
|
if cid not in summary:
|
||||||
|
cat = categories_db.get(cid)
|
||||||
|
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
|
||||||
|
summary[cid]["units_sold"] += item.quantity
|
||||||
|
summary[cid]["revenue"] += item.unit_price * item.quantity
|
||||||
|
if pid not in summary[cid]["products"]:
|
||||||
|
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
|
||||||
|
summary[cid]["products"][pid]["qty"] += item.quantity
|
||||||
|
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
total_rev = sum(v["revenue"] for v in summary.values())
|
||||||
|
total_qty = sum(v["units_sold"] for v in summary.values())
|
||||||
|
cat_list = []
|
||||||
|
for v in sorted(summary.values(), key=lambda x: x["revenue"], reverse=True):
|
||||||
|
cat_total_qty = v["units_sold"]
|
||||||
|
cat_total_rev = v["revenue"]
|
||||||
|
products_list = sorted(
|
||||||
|
[{"name": p["name"], "qty": p["qty"], "revenue": round(p["revenue"], 2),
|
||||||
|
"pct_qty": round(p["qty"] / cat_total_qty * 100, 1) if cat_total_qty else 0,
|
||||||
|
"pct_rev": round(p["revenue"] / cat_total_rev * 100, 1) if cat_total_rev else 0}
|
||||||
|
for p in v["products"].values()],
|
||||||
|
key=lambda x: x["qty"], reverse=True,
|
||||||
|
)
|
||||||
|
cat_list.append({
|
||||||
|
"name": v["name"],
|
||||||
|
"units_sold": v["units_sold"],
|
||||||
|
"revenue": round(v["revenue"], 2),
|
||||||
|
"pct_rev": round(v["revenue"] / total_rev * 100, 1) if total_rev else 0,
|
||||||
|
"pct_qty": round(v["units_sold"] / total_qty * 100, 1) if total_qty else 0,
|
||||||
|
"products": products_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"mode": body.mode,
|
||||||
|
"period_label": period_label,
|
||||||
|
"period_is_workday": period_is_workday,
|
||||||
|
"categories": cat_list,
|
||||||
|
"line_width": printer.line_width,
|
||||||
|
"div_style": load_divider_style(db),
|
||||||
|
}
|
||||||
|
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
|
||||||
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/print/tables")
|
||||||
|
def print_tables(
|
||||||
|
body: PrintAnalyticsBody,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||||
|
|
||||||
|
period_label, period_is_workday = _analytics_period(body, db)
|
||||||
|
|
||||||
|
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||||
|
if body.business_day_id:
|
||||||
|
q = q.filter(Order.business_day_id == body.business_day_id)
|
||||||
|
elif body.from_dt and body.to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
Order.opened_at >= datetime.fromisoformat(body.from_dt),
|
||||||
|
Order.opened_at <= datetime.fromisoformat(body.to_dt),
|
||||||
|
)
|
||||||
|
orders = q.all()
|
||||||
|
|
||||||
|
tables_db = {t.id: t for t in db.query(Table).all()}
|
||||||
|
summary: dict = {}
|
||||||
|
for order in orders:
|
||||||
|
tid = order.table_id
|
||||||
|
if tid not in summary:
|
||||||
|
t = tables_db.get(tid)
|
||||||
|
summary[tid] = {
|
||||||
|
"name": (t.label or f"T{t.number}") if t else f"#{tid}",
|
||||||
|
"order_count": 0, "revenue": 0.0, "durations": [],
|
||||||
|
}
|
||||||
|
summary[tid]["order_count"] += 1
|
||||||
|
summary[tid]["revenue"] += sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
|
||||||
|
if order.closed_at and order.opened_at:
|
||||||
|
summary[tid]["durations"].append((order.closed_at - order.opened_at).total_seconds() / 60)
|
||||||
|
|
||||||
|
table_list = []
|
||||||
|
for entry in summary.values():
|
||||||
|
durations = entry.pop("durations")
|
||||||
|
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
|
||||||
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
|
table_list.append(entry)
|
||||||
|
table_list.sort(key=lambda x: x["revenue"], reverse=True)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"mode": body.mode,
|
||||||
|
"period_label": period_label,
|
||||||
|
"period_is_workday": period_is_workday,
|
||||||
|
"tables": table_list,
|
||||||
|
"line_width": printer.line_width,
|
||||||
|
"div_style": load_divider_style(db),
|
||||||
|
}
|
||||||
|
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
|
||||||
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shift history report
|
# Shift history report
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -578,7 +872,7 @@ def shifts_report(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
from routers.shifts import compute_shift_total
|
from routers.shifts import _enrich_shift
|
||||||
|
|
||||||
q = db.query(WaiterShift)
|
q = db.query(WaiterShift)
|
||||||
if waiter_id:
|
if waiter_id:
|
||||||
@@ -593,28 +887,7 @@ def shifts_report(
|
|||||||
q = q.filter(WaiterShift.ended_at == None)
|
q = q.filter(WaiterShift.ended_at == None)
|
||||||
|
|
||||||
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
||||||
waiters_db = {u.id: u for u in db.query(User).all()}
|
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
|
||||||
|
|
||||||
result = []
|
|
||||||
for shift in shifts:
|
|
||||||
w = waiters_db.get(shift.waiter_id)
|
|
||||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
|
||||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
|
||||||
result.append({
|
|
||||||
"id": shift.id,
|
|
||||||
"waiter_id": shift.waiter_id,
|
|
||||||
"waiter_name": wname,
|
|
||||||
"business_day_id": shift.business_day_id,
|
|
||||||
"started_at": _dt(shift.started_at),
|
|
||||||
"ended_at": _dt(shift.ended_at),
|
|
||||||
"starting_cash": shift.starting_cash,
|
|
||||||
"total_collected": total,
|
|
||||||
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
|
||||||
"is_active": shift.ended_at is None,
|
|
||||||
"notes": shift.notes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"shifts": result}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -631,6 +904,7 @@ def product_performance(
|
|||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
|
from models.waste import WasteLog
|
||||||
|
|
||||||
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||||
if from_dt:
|
if from_dt:
|
||||||
@@ -643,6 +917,24 @@ def product_performance(
|
|||||||
items = q.all()
|
items = q.all()
|
||||||
products_db = {p.id: p for p in db.query(Product).all()}
|
products_db = {p.id: p for p in db.query(Product).all()}
|
||||||
|
|
||||||
|
# Phase 2H: load waste for the same period
|
||||||
|
wq = db.query(WasteLog)
|
||||||
|
if business_day_id:
|
||||||
|
wq = wq.filter(WasteLog.business_day_id == business_day_id)
|
||||||
|
elif from_dt and to_dt:
|
||||||
|
wq = wq.filter(
|
||||||
|
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
|
||||||
|
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
|
||||||
|
)
|
||||||
|
waste_entries = wq.all()
|
||||||
|
waste_by_product: dict = {}
|
||||||
|
for w in waste_entries:
|
||||||
|
pid = w.product_id
|
||||||
|
if pid not in waste_by_product:
|
||||||
|
waste_by_product[pid] = {"waste_qty": 0.0, "waste_cost": 0.0}
|
||||||
|
waste_by_product[pid]["waste_qty"] += w.quantity
|
||||||
|
waste_by_product[pid]["waste_cost"] += w.total_cost or 0.0
|
||||||
|
|
||||||
summary: dict = {}
|
summary: dict = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
pid = item.product_id
|
pid = item.product_id
|
||||||
@@ -656,16 +948,51 @@ def product_performance(
|
|||||||
"category_id": product.category_id if product else None,
|
"category_id": product.category_id if product else None,
|
||||||
"qty_sold": 0,
|
"qty_sold": 0,
|
||||||
"revenue": 0.0,
|
"revenue": 0.0,
|
||||||
|
"total_cost": 0.0,
|
||||||
|
"trackable_profit": 0.0,
|
||||||
|
"uncosted_revenue": 0.0,
|
||||||
|
"uncosted_items": 0,
|
||||||
"order_ids": set(),
|
"order_ids": set(),
|
||||||
}
|
}
|
||||||
summary[pid]["qty_sold"] += item.quantity
|
qty = item.quantity
|
||||||
summary[pid]["revenue"] += item.unit_price * item.quantity
|
revenue = item.unit_price * qty
|
||||||
|
summary[pid]["qty_sold"] += qty
|
||||||
|
summary[pid]["revenue"] += revenue
|
||||||
summary[pid]["order_ids"].add(item.order_id)
|
summary[pid]["order_ids"].add(item.order_id)
|
||||||
|
if item.unit_cost is not None:
|
||||||
|
cost = item.unit_cost * qty
|
||||||
|
summary[pid]["total_cost"] += cost
|
||||||
|
summary[pid]["trackable_profit"] += revenue - cost
|
||||||
|
else:
|
||||||
|
summary[pid]["uncosted_revenue"] += revenue
|
||||||
|
summary[pid]["uncosted_items"] += qty
|
||||||
|
|
||||||
|
# Also include products that only have waste (not sold) in the period
|
||||||
|
for pid, wdata in waste_by_product.items():
|
||||||
|
if pid not in summary:
|
||||||
|
product = products_db.get(pid)
|
||||||
|
if category_id and (not product or product.category_id != category_id):
|
||||||
|
continue
|
||||||
|
summary[pid] = {
|
||||||
|
"product_id": pid,
|
||||||
|
"product_name": product.name if product else f"#{pid}",
|
||||||
|
"category_id": product.category_id if product else None,
|
||||||
|
"qty_sold": 0, "revenue": 0.0, "total_cost": 0.0, "trackable_profit": 0.0,
|
||||||
|
"uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(),
|
||||||
|
}
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for entry in summary.values():
|
for entry in summary.values():
|
||||||
|
pid = entry["product_id"]
|
||||||
|
wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0})
|
||||||
entry["order_count"] = len(entry.pop("order_ids"))
|
entry["order_count"] = len(entry.pop("order_ids"))
|
||||||
entry["revenue"] = round(entry["revenue"], 2)
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
|
entry["total_cost"] = round(entry["total_cost"], 2)
|
||||||
|
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
|
||||||
|
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
|
||||||
|
entry["has_gap"] = entry["uncosted_items"] > 0
|
||||||
|
entry["waste_qty"] = round(wdata["waste_qty"], 3)
|
||||||
|
entry["waste_cost"] = round(wdata["waste_cost"], 2)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
||||||
@@ -800,10 +1127,22 @@ def business_days_list(
|
|||||||
cancelled_orders = [o for o in orders if o.status == "cancelled"]
|
cancelled_orders = [o for o in orders if o.status == "cancelled"]
|
||||||
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
|
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
|
||||||
waiter_ids = {s.waiter_id for s in day_shifts}
|
waiter_ids = {s.waiter_id for s in day_shifts}
|
||||||
revenue = sum(
|
revenue = 0.0
|
||||||
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
total_cost = 0.0
|
||||||
for o in closed_orders
|
trackable_profit = 0.0
|
||||||
)
|
has_gap = False
|
||||||
|
for o in closed_orders:
|
||||||
|
for i in o.items:
|
||||||
|
if i.status not in ("active", "paid"):
|
||||||
|
continue
|
||||||
|
rev = i.unit_price * i.quantity
|
||||||
|
revenue += rev
|
||||||
|
if i.unit_cost is not None:
|
||||||
|
cost = i.unit_cost * i.quantity
|
||||||
|
total_cost += cost
|
||||||
|
trackable_profit += rev - cost
|
||||||
|
else:
|
||||||
|
has_gap = True
|
||||||
opener = waiters_db.get(d.opened_by_id)
|
opener = waiters_db.get(d.opened_by_id)
|
||||||
closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None
|
closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None
|
||||||
result.append({
|
result.append({
|
||||||
@@ -820,6 +1159,9 @@ def business_days_list(
|
|||||||
"waiter_count": len(waiter_ids),
|
"waiter_count": len(waiter_ids),
|
||||||
"shift_count": len(day_shifts),
|
"shift_count": len(day_shifts),
|
||||||
"revenue": round(revenue, 2),
|
"revenue": round(revenue, 2),
|
||||||
|
"total_cost": round(total_cost, 2),
|
||||||
|
"trackable_profit": round(trackable_profit, 2),
|
||||||
|
"has_gap": has_gap,
|
||||||
})
|
})
|
||||||
return {"business_days": result}
|
return {"business_days": result}
|
||||||
|
|
||||||
@@ -841,10 +1183,22 @@ def current_business_day(
|
|||||||
WaiterShift.business_day_id == day.id,
|
WaiterShift.business_day_id == day.id,
|
||||||
WaiterShift.ended_at == None,
|
WaiterShift.ended_at == None,
|
||||||
).all()
|
).all()
|
||||||
revenue = sum(
|
revenue = 0.0
|
||||||
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
total_cost = 0.0
|
||||||
for o in closed_orders
|
trackable_profit = 0.0
|
||||||
)
|
has_gap = False
|
||||||
|
for o in closed_orders:
|
||||||
|
for i in o.items:
|
||||||
|
if i.status not in ("active", "paid"):
|
||||||
|
continue
|
||||||
|
rev = i.unit_price * i.quantity
|
||||||
|
revenue += rev
|
||||||
|
if i.unit_cost is not None:
|
||||||
|
cost = i.unit_cost * i.quantity
|
||||||
|
total_cost += cost
|
||||||
|
trackable_profit += rev - cost
|
||||||
|
else:
|
||||||
|
has_gap = True
|
||||||
|
|
||||||
item_counts: dict = {}
|
item_counts: dict = {}
|
||||||
for o in orders:
|
for o in orders:
|
||||||
@@ -866,6 +1220,9 @@ def current_business_day(
|
|||||||
"id": day.id,
|
"id": day.id,
|
||||||
"opened_at": _dt(day.opened_at),
|
"opened_at": _dt(day.opened_at),
|
||||||
"revenue": round(revenue, 2),
|
"revenue": round(revenue, 2),
|
||||||
|
"total_cost": round(total_cost, 2),
|
||||||
|
"trackable_profit": round(trackable_profit, 2),
|
||||||
|
"has_gap": has_gap,
|
||||||
"orders_closed": len(closed_orders),
|
"orders_closed": len(closed_orders),
|
||||||
"orders_open": len(open_orders),
|
"orders_open": len(open_orders),
|
||||||
"active_waiters": len(active_shifts),
|
"active_waiters": len(active_shifts),
|
||||||
@@ -906,14 +1263,22 @@ def revenue_trends(
|
|||||||
key = dt.strftime("%Y-%m-%d")
|
key = dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
if key not in buckets:
|
if key not in buckets:
|
||||||
buckets[key] = {"date": key, "revenue": 0.0, "orders": 0}
|
buckets[key] = {"date": key, "revenue": 0.0, "profit": 0.0, "has_gap": False, "orders": 0}
|
||||||
rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
|
for i in order.items:
|
||||||
|
if i.status not in ("active", "paid"):
|
||||||
|
continue
|
||||||
|
rev = i.unit_price * i.quantity
|
||||||
buckets[key]["revenue"] += rev
|
buckets[key]["revenue"] += rev
|
||||||
|
if i.unit_cost is not None:
|
||||||
|
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
|
||||||
|
else:
|
||||||
|
buckets[key]["has_gap"] = True
|
||||||
buckets[key]["orders"] += 1
|
buckets[key]["orders"] += 1
|
||||||
|
|
||||||
arr = sorted(buckets.values(), key=lambda x: x["date"])
|
arr = sorted(buckets.values(), key=lambda x: x["date"])
|
||||||
for d in arr:
|
for d in arr:
|
||||||
d["revenue"] = round(d["revenue"], 2)
|
d["revenue"] = round(d["revenue"], 2)
|
||||||
|
d["profit"] = round(d["profit"], 2)
|
||||||
|
|
||||||
if granularity == "daily":
|
if granularity == "daily":
|
||||||
for i, d in enumerate(arr):
|
for i, d in enumerate(arr):
|
||||||
@@ -963,18 +1328,37 @@ def category_performance(
|
|||||||
"color": cat.color if cat and hasattr(cat, "color") else None,
|
"color": cat.color if cat and hasattr(cat, "color") else None,
|
||||||
"units_sold": 0,
|
"units_sold": 0,
|
||||||
"revenue": 0.0,
|
"revenue": 0.0,
|
||||||
|
"total_cost": 0.0,
|
||||||
|
"trackable_profit": 0.0,
|
||||||
|
"uncosted_revenue": 0.0,
|
||||||
|
"has_gap": False,
|
||||||
"product_ids": set(),
|
"product_ids": set(),
|
||||||
}
|
}
|
||||||
summary[cid]["units_sold"] += item.quantity
|
qty = item.quantity
|
||||||
summary[cid]["revenue"] += item.unit_price * item.quantity
|
rev = item.unit_price * qty
|
||||||
|
summary[cid]["units_sold"] += qty
|
||||||
|
summary[cid]["revenue"] += rev
|
||||||
summary[cid]["product_ids"].add(item.product_id)
|
summary[cid]["product_ids"].add(item.product_id)
|
||||||
|
if item.unit_cost is not None:
|
||||||
|
cost = item.unit_cost * qty
|
||||||
|
summary[cid]["total_cost"] += cost
|
||||||
|
summary[cid]["trackable_profit"] += rev - cost
|
||||||
|
else:
|
||||||
|
summary[cid]["uncosted_revenue"] += rev
|
||||||
|
summary[cid]["has_gap"] = True
|
||||||
|
|
||||||
total_rev = sum(v["revenue"] for v in summary.values())
|
total_rev = sum(v["revenue"] for v in summary.values())
|
||||||
|
total_profit = sum(v["trackable_profit"] for v in summary.values())
|
||||||
result = []
|
result = []
|
||||||
for entry in summary.values():
|
for entry in summary.values():
|
||||||
entry["product_count"] = len(entry.pop("product_ids"))
|
entry["product_count"] = len(entry.pop("product_ids"))
|
||||||
entry["revenue"] = round(entry["revenue"], 2)
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
|
entry["total_cost"] = round(entry["total_cost"], 2)
|
||||||
|
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
|
||||||
|
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
|
||||||
|
entry["pct_rev"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
|
||||||
|
entry["pct_profit"] = round((entry["trackable_profit"] / total_profit * 100) if total_profit > 0 else 0, 1)
|
||||||
|
entry["pct"] = entry["pct_rev"]
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
result.sort(key=lambda x: x["revenue"], reverse=True)
|
result.sort(key=lambda x: x["revenue"], reverse=True)
|
||||||
@@ -1063,9 +1447,18 @@ def printer_history(
|
|||||||
printers_db = {p.id: p for p in db.query(Printer).all()}
|
printers_db = {p.id: p for p in db.query(Printer).all()}
|
||||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
|
||||||
|
# Cache orders to avoid repeated queries
|
||||||
|
order_cache = {}
|
||||||
|
def _get_order(oid):
|
||||||
|
if oid not in order_cache:
|
||||||
|
order_cache[oid] = db.query(Order).filter(Order.id == oid).first()
|
||||||
|
return order_cache[oid]
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
seen_order_totals = {} # order_id → total, so we don't double-count reprints
|
||||||
|
|
||||||
for log in logs:
|
for log in logs:
|
||||||
order = db.query(Order).filter(Order.id == log.order_id).first()
|
order = _get_order(log.order_id)
|
||||||
printer = printers_db.get(log.printer_id)
|
printer = printers_db.get(log.printer_id)
|
||||||
try:
|
try:
|
||||||
item_ids = json.loads(log.item_ids)
|
item_ids = json.loads(log.item_ids)
|
||||||
@@ -1077,6 +1470,18 @@ def printer_history(
|
|||||||
if oi:
|
if oi:
|
||||||
pname = oi.product.name if oi.product else f"#{oi.product_id}"
|
pname = oi.product.name if oi.product else f"#{oi.product_id}"
|
||||||
items.append({"name": pname, "quantity": oi.quantity})
|
items.append({"name": pname, "quantity": oi.quantity})
|
||||||
|
|
||||||
|
# Compute full order total (all active+paid items, not just printed slice)
|
||||||
|
order_total = None
|
||||||
|
if order:
|
||||||
|
order_total = sum(
|
||||||
|
i.unit_price * i.quantity
|
||||||
|
for i in order.items
|
||||||
|
if i.status in ("active", "paid")
|
||||||
|
)
|
||||||
|
if log.success and log.order_id not in seen_order_totals:
|
||||||
|
seen_order_totals[log.order_id] = order_total
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"id": log.id,
|
"id": log.id,
|
||||||
"printed_at": _dt(log.printed_at),
|
"printed_at": _dt(log.printed_at),
|
||||||
@@ -1086,11 +1491,14 @@ def printer_history(
|
|||||||
"items": items,
|
"items": items,
|
||||||
"success": log.success,
|
"success": log.success,
|
||||||
"error_message": log.error_message,
|
"error_message": log.error_message,
|
||||||
|
"order_total": round(order_total, 2) if order_total is not None else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
total = len(result)
|
total = len(result)
|
||||||
failed = sum(1 for r in result if not r["success"])
|
failed = sum(1 for r in result if not r["success"])
|
||||||
return {"logs": result, "total": total, "failed": failed}
|
# Sum unique order totals (avoid inflating on reprints of the same order)
|
||||||
|
total_amount = round(sum(seen_order_totals.values()), 2) if seen_order_totals else None
|
||||||
|
return {"logs": result, "total": total, "failed": failed, "total_amount": total_amount}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1124,6 +1532,28 @@ def meta_printers(
|
|||||||
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
|
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta/products")
|
||||||
|
def meta_products(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.product import Product, Category
|
||||||
|
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
|
||||||
|
categories_db = {c.id: c.name for c in db.query(Category).all()}
|
||||||
|
return {
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"category_id": p.category_id,
|
||||||
|
"category_name": categories_db.get(p.category_id, "—"),
|
||||||
|
"price": float(p.base_price),
|
||||||
|
}
|
||||||
|
for p in products
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CSV export endpoints
|
# CSV export endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1160,6 +1590,11 @@ def shifts_export(
|
|||||||
"waiter": s["waiter_name"],
|
"waiter": s["waiter_name"],
|
||||||
"started_at": s["started_at"],
|
"started_at": s["started_at"],
|
||||||
"ended_at": s["ended_at"] or "",
|
"ended_at": s["ended_at"] or "",
|
||||||
|
"duration_hours": s.get("duration_hours", ""),
|
||||||
|
"hourly_rate": s.get("hourly_rate_snapshot", ""),
|
||||||
|
"shift_pay": s.get("shift_pay", ""),
|
||||||
|
"counted_cash_end": s.get("counted_cash_end", ""),
|
||||||
|
"cash_discrepancy": s.get("cash_discrepancy", ""),
|
||||||
"starting_cash": s["starting_cash"],
|
"starting_cash": s["starting_cash"],
|
||||||
"total_collected": s["total_collected"],
|
"total_collected": s["total_collected"],
|
||||||
"net_to_deliver": s["net_to_deliver"],
|
"net_to_deliver": s["net_to_deliver"],
|
||||||
@@ -1267,3 +1702,98 @@ def cancellations_export(
|
|||||||
} for c in data["cancellations"]]
|
} for c in data["cancellations"]]
|
||||||
date_str = (from_dt or "")[:10]
|
date_str = (from_dt or "")[:10]
|
||||||
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
return _csv_response(rows, f"cancellations-{date_str}.csv")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2L — Discount audit report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/discounts")
|
||||||
|
def discounts_report(
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
applied_by: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.order import OrderDiscount, OrderItem
|
||||||
|
|
||||||
|
q = db.query(OrderDiscount)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.join(Order, Order.id == OrderDiscount.order_id).filter(Order.business_day_id == business_day_id)
|
||||||
|
elif from_dt and to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
OrderDiscount.applied_at >= datetime.fromisoformat(from_dt),
|
||||||
|
OrderDiscount.applied_at <= datetime.fromisoformat(to_dt),
|
||||||
|
)
|
||||||
|
if applied_by:
|
||||||
|
q = q.filter(OrderDiscount.applied_by == applied_by)
|
||||||
|
|
||||||
|
discounts = q.order_by(OrderDiscount.applied_at.desc()).all()
|
||||||
|
|
||||||
|
users_map = {u.id: u for u in db.query(User).all()}
|
||||||
|
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
|
||||||
|
# Preload orders for table names
|
||||||
|
order_ids = {d.order_id for d in discounts}
|
||||||
|
orders_map = {}
|
||||||
|
if order_ids:
|
||||||
|
for o in db.query(Order).filter(Order.id.in_(order_ids)).all():
|
||||||
|
orders_map[o.id] = o
|
||||||
|
|
||||||
|
def _compute_discount_amount(d: OrderDiscount) -> float:
|
||||||
|
"""Compute the actual euro amount discounted."""
|
||||||
|
order = orders_map.get(d.order_id)
|
||||||
|
if d.discount_type == "fixed":
|
||||||
|
return round(d.discount_value, 2)
|
||||||
|
if d.discount_type == "percent":
|
||||||
|
if d.item_id:
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
|
||||||
|
if item:
|
||||||
|
base = item.unit_price * item.quantity
|
||||||
|
return round(base * d.discount_value / 100, 2)
|
||||||
|
elif order:
|
||||||
|
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
|
||||||
|
return round(base * d.discount_value / 100, 2)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
result = []
|
||||||
|
total_discount_value = 0.0
|
||||||
|
by_waiter: dict = {}
|
||||||
|
|
||||||
|
for d in discounts:
|
||||||
|
order = orders_map.get(d.order_id)
|
||||||
|
applier = users_map.get(d.applied_by)
|
||||||
|
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
|
||||||
|
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||||
|
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
|
||||||
|
discount_amount = _compute_discount_amount(d)
|
||||||
|
total_discount_value += discount_amount
|
||||||
|
|
||||||
|
if d.applied_by not in by_waiter:
|
||||||
|
by_waiter[d.applied_by] = {"waiter_id": d.applied_by, "waiter_name": applier_name, "count": 0, "total_value": 0.0}
|
||||||
|
by_waiter[d.applied_by]["count"] += 1
|
||||||
|
by_waiter[d.applied_by]["total_value"] = round(by_waiter[d.applied_by]["total_value"] + discount_amount, 2)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"id": d.id,
|
||||||
|
"order_id": d.order_id,
|
||||||
|
"table_name": table_name,
|
||||||
|
"applied_by_id": d.applied_by,
|
||||||
|
"applied_by_name": applier_name,
|
||||||
|
"applied_at": _dt(d.applied_at),
|
||||||
|
"discount_type": d.discount_type,
|
||||||
|
"discount_value": d.discount_value,
|
||||||
|
"discount_amount": discount_amount,
|
||||||
|
"order_total_before": round(order_total, 2) if order_total is not None else None,
|
||||||
|
"item_id": d.item_id,
|
||||||
|
"reason": d.reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"discounts": result,
|
||||||
|
"total_discount_value": round(total_discount_value, 2),
|
||||||
|
"order_count": len({d["order_id"] for d in result}),
|
||||||
|
"by_waiter": sorted(by_waiter.values(), key=lambda x: -x["total_value"]),
|
||||||
|
}
|
||||||
|
|||||||
171
local_backend/routers/reservations.py
Normal file
171
local_backend/routers/reservations.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.reservation import Reservation
|
||||||
|
from models.table import Table
|
||||||
|
from schemas.reservation import (
|
||||||
|
ReservationCreate, ReservationUpdate, ReservationStatusUpdate, ReservationOut,
|
||||||
|
)
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
VALID_STATUSES = {"pending", "arrived", "cancelled", "no_show"}
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich(r: Reservation) -> ReservationOut:
|
||||||
|
out = ReservationOut.model_validate(r)
|
||||||
|
if r.table:
|
||||||
|
out.table_label = r.table.label or f"T{r.table.number}"
|
||||||
|
if r.created_by:
|
||||||
|
out.created_by_name = (
|
||||||
|
r.created_by.full_name or r.created_by.nickname or r.created_by.username
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ReservationOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_reservation(
|
||||||
|
body: ReservationCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if body.table_id:
|
||||||
|
if not db.query(Table).filter(Table.id == body.table_id, Table.is_active == True).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||||
|
|
||||||
|
r = Reservation(
|
||||||
|
guest_name=body.guest_name,
|
||||||
|
party_size=body.party_size,
|
||||||
|
phone=body.phone,
|
||||||
|
email=body.email,
|
||||||
|
note=body.note,
|
||||||
|
reserved_for=body.reserved_for,
|
||||||
|
table_id=body.table_id,
|
||||||
|
source=body.source,
|
||||||
|
online_customer_id=body.online_customer_id,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(r)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(r)
|
||||||
|
return _enrich(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ReservationOut])
|
||||||
|
def list_reservations(
|
||||||
|
tab: Optional[str] = Query(None), # "upcoming" | "history" — if omitted returns all
|
||||||
|
date: Optional[str] = Query(None), # ISO date string YYYY-MM-DD to filter by day
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(Reservation)
|
||||||
|
|
||||||
|
if date:
|
||||||
|
try:
|
||||||
|
day = datetime.fromisoformat(date).date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||||
|
from sqlalchemy import func
|
||||||
|
q = q.filter(func.date(Reservation.reserved_for) == day)
|
||||||
|
|
||||||
|
if tab == "upcoming":
|
||||||
|
# pending + no_show (actionable), ordered soonest first
|
||||||
|
q = q.filter(Reservation.status.in_(["pending", "no_show"]))
|
||||||
|
q = q.order_by(Reservation.reserved_for.asc())
|
||||||
|
elif tab == "history":
|
||||||
|
# arrived, cancelled, plus no_show/pending that are clearly in the past
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
q = q.filter(
|
||||||
|
(Reservation.status.in_(["arrived", "cancelled"])) |
|
||||||
|
(
|
||||||
|
Reservation.status.in_(["no_show", "pending"]) &
|
||||||
|
(Reservation.reserved_for < now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
q = q.order_by(Reservation.reserved_for.desc())
|
||||||
|
else:
|
||||||
|
q = q.order_by(Reservation.reserved_for.asc())
|
||||||
|
|
||||||
|
reservations = q.all()
|
||||||
|
return [_enrich(r) for r in reservations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{reservation_id}", response_model=ReservationOut)
|
||||||
|
def get_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
return _enrich(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{reservation_id}", response_model=ReservationOut)
|
||||||
|
def update_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
body: ReservationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if "table_id" in updates and updates["table_id"] is not None:
|
||||||
|
if not db.query(Table).filter(Table.id == updates["table_id"], Table.is_active == True).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||||
|
|
||||||
|
if "status" in updates and updates["status"] not in VALID_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid status: {updates['status']}")
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(r, field, value)
|
||||||
|
|
||||||
|
r.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(r)
|
||||||
|
return _enrich(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{reservation_id}/status", response_model=ReservationOut)
|
||||||
|
def update_reservation_status(
|
||||||
|
reservation_id: int,
|
||||||
|
body: ReservationStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if body.status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
|
||||||
|
|
||||||
|
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
|
||||||
|
r.status = body.status
|
||||||
|
r.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(r)
|
||||||
|
return _enrich(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{reservation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
db.delete(r)
|
||||||
|
db.commit()
|
||||||
187
local_backend/routers/schedule.py
Normal file
187
local_backend/routers/schedule.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.schedule import ScheduledShift
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
from models.user import User
|
||||||
|
from schemas.schedule import ScheduledShiftCreate, ScheduledShiftUpdate, ScheduledShiftOut
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_hours(start: str, end: str) -> float:
|
||||||
|
"""Compute hours between two HH:MM strings. Handles overnight spans."""
|
||||||
|
sh, sm = int(start[:2]), int(start[3:5])
|
||||||
|
eh, em = int(end[:2]), int(end[3:5])
|
||||||
|
mins = (eh * 60 + em) - (sh * 60 + sm)
|
||||||
|
if mins <= 0:
|
||||||
|
mins += 24 * 60 # overnight
|
||||||
|
return round(mins / 60, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich(s: ScheduledShift) -> dict:
|
||||||
|
u = s.user
|
||||||
|
user_name = (u.full_name or u.username) if u else f"#{s.user_id}"
|
||||||
|
hourly_rate = u.hourly_rate if u else None
|
||||||
|
dur = _duration_hours(s.start_time, s.end_time)
|
||||||
|
estimated_pay = round(dur * hourly_rate, 2) if hourly_rate else None
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"user_name": user_name,
|
||||||
|
"hourly_rate": hourly_rate,
|
||||||
|
"scheduled_date": s.scheduled_date,
|
||||||
|
"start_time": s.start_time,
|
||||||
|
"end_time": s.end_time,
|
||||||
|
"notes": s.notes,
|
||||||
|
"created_by_id": s.created_by_id,
|
||||||
|
"created_at": s.created_at,
|
||||||
|
"duration_hours": dur,
|
||||||
|
"estimated_pay": estimated_pay,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ScheduledShiftOut])
|
||||||
|
def list_schedule(
|
||||||
|
from_date: Optional[date] = Query(default=None, alias="from"),
|
||||||
|
to_date: Optional[date] = Query(default=None, alias="to"),
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(ScheduledShift)
|
||||||
|
if from_date:
|
||||||
|
q = q.filter(ScheduledShift.scheduled_date >= from_date)
|
||||||
|
if to_date:
|
||||||
|
q = q.filter(ScheduledShift.scheduled_date <= to_date)
|
||||||
|
if user_id:
|
||||||
|
q = q.filter(ScheduledShift.user_id == user_id)
|
||||||
|
shifts = q.order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
|
||||||
|
return [_enrich(s) for s in shifts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ScheduledShiftOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_scheduled_shift(
|
||||||
|
body: ScheduledShiftCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
target = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
s = ScheduledShift(
|
||||||
|
user_id=body.user_id,
|
||||||
|
scheduled_date=body.scheduled_date,
|
||||||
|
start_time=body.start_time,
|
||||||
|
end_time=body.end_time,
|
||||||
|
notes=body.notes,
|
||||||
|
created_by_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return _enrich(s)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{shift_id}", response_model=ScheduledShiftOut)
|
||||||
|
def update_scheduled_shift(
|
||||||
|
shift_id: int,
|
||||||
|
body: ScheduledShiftUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(status_code=404, detail="Scheduled shift not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(s, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return _enrich(s)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_scheduled_shift(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(status_code=404, detail="Scheduled shift not found")
|
||||||
|
db.delete(s)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/week")
|
||||||
|
def week_schedule(
|
||||||
|
week_start: Optional[date] = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
"""Current (or specified) week: scheduled shifts + actual WaiterShifts for comparison."""
|
||||||
|
if week_start is None:
|
||||||
|
today = date.today()
|
||||||
|
week_start = today - timedelta(days=today.weekday()) # Monday
|
||||||
|
week_end = week_start + timedelta(days=6) # Sunday
|
||||||
|
|
||||||
|
scheduled = db.query(ScheduledShift).filter(
|
||||||
|
ScheduledShift.scheduled_date >= week_start,
|
||||||
|
ScheduledShift.scheduled_date <= week_end,
|
||||||
|
).order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
|
||||||
|
|
||||||
|
# Actual shifts that started within the week
|
||||||
|
week_start_dt = datetime.combine(week_start, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
week_end_dt = datetime.combine(week_end, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||||
|
actuals = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.started_at >= week_start_dt,
|
||||||
|
WaiterShift.started_at <= week_end_dt,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
waiters = {u.id: u for u in db.query(User).filter(User.role == "waiter", User.is_active == True).all()}
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
if not dt:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
# Build actual shifts lookup: (user_id, date_str) → list of actual shifts
|
||||||
|
actuals_map: dict = {}
|
||||||
|
for a in actuals:
|
||||||
|
d = a.started_at.date() if a.started_at else None
|
||||||
|
if d:
|
||||||
|
key = (a.waiter_id, d.isoformat())
|
||||||
|
if key not in actuals_map:
|
||||||
|
actuals_map[key] = []
|
||||||
|
actuals_map[key].append({
|
||||||
|
"id": a.id,
|
||||||
|
"started_at": _dt(a.started_at),
|
||||||
|
"ended_at": _dt(a.ended_at),
|
||||||
|
"is_active": a.ended_at is None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Estimate total weekly labor cost
|
||||||
|
total_estimated_pay = 0.0
|
||||||
|
scheduled_out = []
|
||||||
|
for s in scheduled:
|
||||||
|
enriched = _enrich(s)
|
||||||
|
key = (s.user_id, s.scheduled_date.isoformat())
|
||||||
|
enriched["actual_shifts"] = actuals_map.get(key, [])
|
||||||
|
scheduled_out.append(enriched)
|
||||||
|
if enriched["estimated_pay"]:
|
||||||
|
total_estimated_pay += enriched["estimated_pay"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"week_start": week_start.isoformat(),
|
||||||
|
"week_end": week_end.isoformat(),
|
||||||
|
"scheduled": scheduled_out,
|
||||||
|
"total_estimated_pay": round(total_estimated_pay, 2),
|
||||||
|
"waiters": [
|
||||||
|
{"id": u.id, "name": u.full_name or u.username, "hourly_rate": u.hourly_rate}
|
||||||
|
for u in sorted(waiters.values(), key=lambda x: x.full_name or x.username)
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ VALID_SETTINGS = {
|
|||||||
# Print layout
|
# Print layout
|
||||||
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||||
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
||||||
|
"print.dot_style": "Dot-leader style for item lines: STYLE:SIZE:BOLD where STYLE is dot_space|dot|dash_space|underscore, SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1",
|
||||||
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
|
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
|
||||||
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
||||||
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
|
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
|
||||||
@@ -37,6 +38,9 @@ VALID_SETTINGS = {
|
|||||||
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
|
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
|
||||||
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
|
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
|
||||||
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
|
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
|
||||||
|
# Beep settings
|
||||||
|
"print.beep_on_ticket": "Play beep when a kitchen ticket prints: 'true' | 'false'",
|
||||||
|
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
@@ -50,10 +54,11 @@ DEFAULTS = {
|
|||||||
"shifts.waiter_self_end": "true",
|
"shifts.waiter_self_end": "true",
|
||||||
"business_day.force_close_allowed": "true",
|
"business_day.force_close_allowed": "true",
|
||||||
"system.timezone": "Europe/Athens",
|
"system.timezone": "Europe/Athens",
|
||||||
"ui.table_colours": "",
|
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
|
||||||
"dev.spoof_printing": "false",
|
"dev.spoof_printing": "false",
|
||||||
"print.ticket_mode": "detailed",
|
"print.ticket_mode": "detailed",
|
||||||
"print.divider_style": "dash",
|
"print.divider_style": "dash",
|
||||||
|
"print.dot_style": "dot_space:0:0",
|
||||||
"print.font_order_number": "48:1:0",
|
"print.font_order_number": "48:1:0",
|
||||||
"print.font_meta": "0:0:0",
|
"print.font_meta": "0:0:0",
|
||||||
"print.font_item_name": "16:1:0",
|
"print.font_item_name": "16:1:0",
|
||||||
@@ -63,6 +68,8 @@ DEFAULTS = {
|
|||||||
"print.font_ingredient": "0:0:0",
|
"print.font_ingredient": "0:0:0",
|
||||||
"print.font_item_note": "0:0:0",
|
"print.font_item_note": "0:0:0",
|
||||||
"print.font_order_note": "0:1:0",
|
"print.font_order_note": "0:1:0",
|
||||||
|
"print.beep_on_ticket": "true",
|
||||||
|
"print.beep_pattern": "double",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,46 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
|
|||||||
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_shift_pay(shift: WaiterShift) -> dict:
|
||||||
|
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start = shift.started_at
|
||||||
|
if start and start.tzinfo is None:
|
||||||
|
start = start.replace(tzinfo=timezone.utc)
|
||||||
|
end = shift.ended_at
|
||||||
|
if end and end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
reference = end or now
|
||||||
|
|
||||||
|
total_seconds = (reference - start).total_seconds() if start else 0
|
||||||
|
|
||||||
|
# Subtract completed breaks
|
||||||
|
break_seconds = 0
|
||||||
|
for b in shift.breaks:
|
||||||
|
bs = b.started_at
|
||||||
|
be = b.ended_at
|
||||||
|
if bs and be:
|
||||||
|
if bs.tzinfo is None:
|
||||||
|
bs = bs.replace(tzinfo=timezone.utc)
|
||||||
|
if be.tzinfo is None:
|
||||||
|
be = be.replace(tzinfo=timezone.utc)
|
||||||
|
break_seconds += (be - bs).total_seconds()
|
||||||
|
|
||||||
|
worked_seconds = max(0, total_seconds - break_seconds)
|
||||||
|
duration_hours = round(worked_seconds / 3600, 4)
|
||||||
|
|
||||||
|
shift_pay = None
|
||||||
|
if shift.hourly_rate_snapshot is not None:
|
||||||
|
shift_pay = round(duration_hours * shift.hourly_rate_snapshot, 2)
|
||||||
|
|
||||||
|
return {"duration_hours": round(duration_hours, 2), "shift_pay": shift_pay}
|
||||||
|
|
||||||
|
|
||||||
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||||
w = shift.waiter
|
w = shift.waiter
|
||||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||||
|
pay_data = compute_shift_pay(shift)
|
||||||
return {
|
return {
|
||||||
"id": shift.id,
|
"id": shift.id,
|
||||||
"waiter_id": shift.waiter_id,
|
"waiter_id": shift.waiter_id,
|
||||||
@@ -51,6 +87,12 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|||||||
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||||
"is_active": shift.ended_at is None,
|
"is_active": shift.ended_at is None,
|
||||||
"notes": shift.notes,
|
"notes": shift.notes,
|
||||||
|
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
|
||||||
|
"duration_hours": pay_data["duration_hours"],
|
||||||
|
"shift_pay": pay_data["shift_pay"],
|
||||||
|
# Phase 2E
|
||||||
|
"counted_cash_end": shift.counted_cash_end,
|
||||||
|
"cash_discrepancy": shift.cash_discrepancy,
|
||||||
"breaks": [
|
"breaks": [
|
||||||
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||||
for b in shift.breaks
|
for b in shift.breaks
|
||||||
@@ -97,10 +139,12 @@ def start_shift(
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||||
|
|
||||||
|
target_user = db.query(User).filter(User.id == target_id).first()
|
||||||
shift = WaiterShift(
|
shift = WaiterShift(
|
||||||
waiter_id=target_id,
|
waiter_id=target_id,
|
||||||
business_day_id=active_day.id,
|
business_day_id=active_day.id,
|
||||||
starting_cash=body.starting_cash,
|
starting_cash=body.starting_cash,
|
||||||
|
hourly_rate_snapshot=target_user.hourly_rate if target_user else None,
|
||||||
)
|
)
|
||||||
db.add(shift)
|
db.add(shift)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -125,10 +169,16 @@ def end_shift(
|
|||||||
raise HTTPException(status_code=404, detail="No active shift found")
|
raise HTTPException(status_code=404, detail="No active shift found")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
shift.total_collected = compute_shift_total(shift.id, db)
|
total = compute_shift_total(shift.id, db)
|
||||||
|
shift.total_collected = total
|
||||||
shift.ended_at = now
|
shift.ended_at = now
|
||||||
if body.notes:
|
if body.notes:
|
||||||
shift.notes = body.notes
|
shift.notes = body.notes
|
||||||
|
# Phase 2E: cash reconciliation
|
||||||
|
if body.counted_cash_end is not None:
|
||||||
|
shift.counted_cash_end = body.counted_cash_end
|
||||||
|
expected = (shift.starting_cash or 0.0) + total
|
||||||
|
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||||
|
|
||||||
open_break = db.query(ShiftBreak).filter(
|
open_break = db.query(ShiftBreak).filter(
|
||||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
@@ -169,6 +219,7 @@ def manager_start_shift(
|
|||||||
waiter_id=body.waiter_id,
|
waiter_id=body.waiter_id,
|
||||||
business_day_id=active_day.id,
|
business_day_id=active_day.id,
|
||||||
starting_cash=body.starting_cash,
|
starting_cash=body.starting_cash,
|
||||||
|
hourly_rate_snapshot=target.hourly_rate,
|
||||||
)
|
)
|
||||||
db.add(shift)
|
db.add(shift)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -191,10 +242,16 @@ def manager_end_shift(
|
|||||||
raise HTTPException(status_code=404, detail="Active shift not found")
|
raise HTTPException(status_code=404, detail="Active shift not found")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
shift.total_collected = compute_shift_total(shift.id, db)
|
total = compute_shift_total(shift.id, db)
|
||||||
|
shift.total_collected = total
|
||||||
shift.ended_at = now
|
shift.ended_at = now
|
||||||
if body.notes:
|
if body.notes:
|
||||||
shift.notes = body.notes
|
shift.notes = body.notes
|
||||||
|
# Phase 2E: cash reconciliation
|
||||||
|
if body.counted_cash_end is not None:
|
||||||
|
shift.counted_cash_end = body.counted_cash_end
|
||||||
|
expected = (shift.starting_cash or 0.0) + total
|
||||||
|
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
|
||||||
|
|
||||||
open_break = db.query(ShiftBreak).filter(
|
open_break = db.query(ShiftBreak).filter(
|
||||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
@@ -356,13 +413,15 @@ def get_shift_summary(
|
|||||||
OrderItem.status == "paid",
|
OrderItem.status == "paid",
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
from datetime import timezone as tz
|
||||||
|
upper_bound = shift.ended_at or datetime.now(tz.utc)
|
||||||
ordered_items = db.query(OrderItem).options(
|
ordered_items = db.query(OrderItem).options(
|
||||||
joinedload(OrderItem.product),
|
joinedload(OrderItem.product),
|
||||||
joinedload(OrderItem.order),
|
joinedload(OrderItem.order),
|
||||||
).filter(
|
).filter(
|
||||||
OrderItem.added_by == waiter_id,
|
OrderItem.added_by == waiter_id,
|
||||||
OrderItem.added_at >= shift.started_at,
|
OrderItem.added_at >= shift.started_at,
|
||||||
OrderItem.added_at <= shift.ended_at,
|
OrderItem.added_at <= upper_bound,
|
||||||
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,16 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
|
|||||||
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")
|
||||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
|
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
|
||||||
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers/test-beep")
|
||||||
|
def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, 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")
|
||||||
|
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
|
||||||
return {"success": success, "error": error}
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.table import Table, TableGroup
|
from models.table import Table, TableGroup
|
||||||
from models.order import Order
|
from models.order import Order
|
||||||
|
from models.reservation import Reservation
|
||||||
from models.user import User, WaiterZone
|
from models.user import User, WaiterZone
|
||||||
from schemas.table import (
|
from schemas.table import (
|
||||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, UpcomingReservation,
|
||||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from services.sse_bus import broadcast_sync
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
# Tables with a pending reservation due within this many hours get the RESERVED badge
|
||||||
|
RESERVATION_LOOKAHEAD_HOURS = 4
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +41,13 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
for idx, group_id in enumerate(body):
|
||||||
|
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||||
@@ -91,10 +103,32 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
|||||||
).all()
|
).all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fetch pending reservations due within the lookahead window for the RESERVED badge
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
lookahead = now + timedelta(hours=RESERVATION_LOOKAHEAD_HOURS)
|
||||||
|
upcoming_reservations = (
|
||||||
|
db.query(Reservation)
|
||||||
|
.filter(
|
||||||
|
Reservation.status == "pending",
|
||||||
|
Reservation.table_id.isnot(None),
|
||||||
|
Reservation.reserved_for >= now,
|
||||||
|
Reservation.reserved_for <= lookahead,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# Map table_id → soonest upcoming reservation
|
||||||
|
reservation_by_table: dict[int, Reservation] = {}
|
||||||
|
for res in sorted(upcoming_reservations, key=lambda r: r.reserved_for):
|
||||||
|
if res.table_id not in reservation_by_table:
|
||||||
|
reservation_by_table[res.table_id] = res
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for t in tables:
|
for t in tables:
|
||||||
out = TableOut.model_validate(t)
|
out = TableOut.model_validate(t)
|
||||||
out.has_active_order = t.id in active_table_ids
|
out.has_active_order = t.id in active_table_ids
|
||||||
|
res = reservation_by_table.get(t.id)
|
||||||
|
if res:
|
||||||
|
out.upcoming_reservation = UpcomingReservation.model_validate(res)
|
||||||
result.append(out)
|
result.append(out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
223
local_backend/routers/tabs.py
Normal file
223
local_backend/routers/tabs.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.tabs import Tab, TabEntry, TabPayment
|
||||||
|
from models.customers import Customer
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.user import User
|
||||||
|
from schemas.tabs import TabOut, TabEntryOut, TabPaymentOut, TabPayRequest, TabForgiveRequest, TabCloseRequest
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _tab_out(tab: Tab) -> dict:
|
||||||
|
c = tab.customer
|
||||||
|
customer_name = c.name if c else None
|
||||||
|
if c and c.nickname:
|
||||||
|
customer_name = f"{c.name} «{c.nickname}»"
|
||||||
|
customer_phone = c.phone if c else None
|
||||||
|
|
||||||
|
total_charged = round(sum(e.amount for e in tab.entries), 2)
|
||||||
|
total_paid = round(sum(p.amount for p in tab.payments), 2)
|
||||||
|
balance = round(total_charged - total_paid, 2)
|
||||||
|
|
||||||
|
closed_by_name = None
|
||||||
|
if tab.closed_by:
|
||||||
|
closed_by_name = tab.closed_by.full_name or tab.closed_by.username
|
||||||
|
|
||||||
|
entries_out = []
|
||||||
|
for e in sorted(tab.entries, key=lambda x: x.created_at):
|
||||||
|
creator = e.created_by.full_name or e.created_by.username if e.created_by else None
|
||||||
|
entries_out.append({
|
||||||
|
"id": e.id, "tab_id": e.tab_id, "order_id": e.order_id,
|
||||||
|
"order_item_id": e.order_item_id, "amount": e.amount,
|
||||||
|
"description": e.description, "created_by_id": e.created_by_id,
|
||||||
|
"created_by_name": creator, "created_at": e.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
payments_out = []
|
||||||
|
for p in sorted(tab.payments, key=lambda x: x.created_at):
|
||||||
|
receiver = p.received_by.full_name or p.received_by.username if p.received_by else None
|
||||||
|
payments_out.append({
|
||||||
|
"id": p.id, "tab_id": p.tab_id, "amount": p.amount,
|
||||||
|
"payment_method": p.payment_method, "received_by_id": p.received_by_id,
|
||||||
|
"received_by_name": receiver, "notes": p.notes, "created_at": p.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": tab.id,
|
||||||
|
"customer_id": tab.customer_id,
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"customer_phone": customer_phone,
|
||||||
|
"status": tab.status,
|
||||||
|
"opened_at": tab.opened_at,
|
||||||
|
"closed_at": tab.closed_at,
|
||||||
|
"closed_by_id": tab.closed_by_id,
|
||||||
|
"notes": tab.notes,
|
||||||
|
"balance": balance,
|
||||||
|
"total_charged": total_charged,
|
||||||
|
"total_paid": total_paid,
|
||||||
|
"entries": entries_out,
|
||||||
|
"payments": payments_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TabOut])
|
||||||
|
def list_tabs(
|
||||||
|
tab_status: Optional[str] = None,
|
||||||
|
customer_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Tab)
|
||||||
|
if tab_status:
|
||||||
|
q = q.filter(Tab.status == tab_status)
|
||||||
|
else:
|
||||||
|
q = q.filter(Tab.status == "open") # default: only open tabs
|
||||||
|
if customer_id:
|
||||||
|
q = q.filter(Tab.customer_id == customer_id)
|
||||||
|
tabs = q.order_by(Tab.opened_at.desc()).all()
|
||||||
|
result = [_tab_out(t) for t in tabs]
|
||||||
|
# Sort open tabs by balance descending
|
||||||
|
result.sort(key=lambda x: x["balance"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tab_id}", response_model=TabOut)
|
||||||
|
def get_tab(tab_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
tab = db.query(Tab).filter(Tab.id == tab_id).first()
|
||||||
|
if not tab:
|
||||||
|
raise HTTPException(status_code=404, detail="Tab not found")
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=TabOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def open_tab(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
customer = db.query(Customer).filter(Customer.id == customer_id, Customer.is_active == True).first()
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
existing = db.query(Tab).filter(Tab.customer_id == customer_id, Tab.status == "open").first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Customer already has an open tab")
|
||||||
|
tab = Tab(customer_id=customer_id)
|
||||||
|
db.add(tab)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tab)
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tab_id}/entries", response_model=TabOut)
|
||||||
|
def add_tab_entry(
|
||||||
|
tab_id: int,
|
||||||
|
order_id: Optional[int] = None,
|
||||||
|
order_item_id: Optional[int] = None,
|
||||||
|
amount: float = 0.0,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
|
||||||
|
if not tab:
|
||||||
|
raise HTTPException(status_code=404, detail="Open tab not found")
|
||||||
|
if amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||||
|
entry = TabEntry(
|
||||||
|
tab_id=tab_id, order_id=order_id, order_item_id=order_item_id,
|
||||||
|
amount=amount, description=description, created_by_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tab)
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tab_id}/pay", response_model=TabOut)
|
||||||
|
def pay_tab(
|
||||||
|
tab_id: int,
|
||||||
|
body: TabPayRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
|
||||||
|
if not tab:
|
||||||
|
raise HTTPException(status_code=404, detail="Open tab not found")
|
||||||
|
if body.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||||
|
total_charged = sum(e.amount for e in tab.entries)
|
||||||
|
total_paid = sum(p.amount for p in tab.payments)
|
||||||
|
balance = total_charged - total_paid
|
||||||
|
if body.amount > balance + 0.005:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Payment exceeds balance ({balance:.2f})")
|
||||||
|
payment = TabPayment(
|
||||||
|
tab_id=tab_id, amount=body.amount,
|
||||||
|
payment_method=body.payment_method, notes=body.notes,
|
||||||
|
received_by_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(payment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tab)
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tab_id}/close", response_model=TabOut)
|
||||||
|
def close_tab(
|
||||||
|
tab_id: int,
|
||||||
|
body: TabCloseRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
|
||||||
|
if not tab:
|
||||||
|
raise HTTPException(status_code=404, detail="Open tab not found")
|
||||||
|
total_charged = sum(e.amount for e in tab.entries)
|
||||||
|
total_paid = sum(p.amount for p in tab.payments)
|
||||||
|
balance = round(total_charged - total_paid, 2)
|
||||||
|
if balance > 0.005:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Tab still has a balance of €{balance:.2f} — pay it off first or use /forgive")
|
||||||
|
tab.status = "closed"
|
||||||
|
tab.closed_at = _utcnow()
|
||||||
|
tab.closed_by_id = user.id
|
||||||
|
if body.notes:
|
||||||
|
tab.notes = body.notes
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tab)
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tab_id}/forgive", response_model=TabOut)
|
||||||
|
def forgive_tab(
|
||||||
|
tab_id: int,
|
||||||
|
body: TabForgiveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
|
||||||
|
if not tab:
|
||||||
|
raise HTTPException(status_code=404, detail="Open tab not found")
|
||||||
|
tab.status = "forgiven"
|
||||||
|
tab.closed_at = _utcnow()
|
||||||
|
tab.closed_by_id = user.id
|
||||||
|
if body.reason:
|
||||||
|
tab.notes = body.reason
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tab)
|
||||||
|
return _tab_out(tab)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customer tabs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/customer/{customer_id}", response_model=List[TabOut])
|
||||||
|
def customer_tabs(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
tabs = db.query(Tab).filter(Tab.customer_id == customer_id).order_by(
|
||||||
|
Tab.status.asc(), # open first
|
||||||
|
Tab.opened_at.desc(),
|
||||||
|
).all()
|
||||||
|
return [_tab_out(t) for t in tabs]
|
||||||
@@ -52,10 +52,14 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
|||||||
full_name=body.full_name,
|
full_name=body.full_name,
|
||||||
nickname=body.nickname,
|
nickname=body.nickname,
|
||||||
mobile_phone=body.mobile_phone,
|
mobile_phone=body.mobile_phone,
|
||||||
|
email=body.email,
|
||||||
|
note=body.note,
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||||
|
db.commit()
|
||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
182
local_backend/routers/waste.py
Normal file
182
local_backend/routers/waste.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.waste import WasteLog
|
||||||
|
from models.product import Product
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.user import User
|
||||||
|
from schemas.waste import WasteLogCreate, WasteLogOut, VALID_REASONS, REASON_LABELS
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_cost(product: Product) -> Optional[float]:
|
||||||
|
"""Return product's current effective cost (breakdown sum or simple), or None."""
|
||||||
|
if product.cost_breakdown:
|
||||||
|
try:
|
||||||
|
entries = json.loads(product.cost_breakdown)
|
||||||
|
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
|
||||||
|
if total > 0:
|
||||||
|
return total
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if product.cost_simple:
|
||||||
|
return product.cost_simple
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _waste_out(w: WasteLog) -> dict:
|
||||||
|
product_name = w.product.name if w.product else f"#{w.product_id}"
|
||||||
|
logger_name = None
|
||||||
|
if w.logged_by:
|
||||||
|
logger_name = w.logged_by.full_name or w.logged_by.username
|
||||||
|
return {
|
||||||
|
"id": w.id,
|
||||||
|
"product_id": w.product_id,
|
||||||
|
"product_name": product_name,
|
||||||
|
"quantity": w.quantity,
|
||||||
|
"unit_cost_snapshot": w.unit_cost_snapshot,
|
||||||
|
"total_cost": w.total_cost,
|
||||||
|
"reason": w.reason,
|
||||||
|
"reason_label": REASON_LABELS.get(w.reason, w.reason),
|
||||||
|
"reason_notes": w.reason_notes,
|
||||||
|
"logged_by_id": w.logged_by_id,
|
||||||
|
"logged_by_name": logger_name,
|
||||||
|
"business_day_id": w.business_day_id,
|
||||||
|
"logged_at": w.logged_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_waste(
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from datetime import datetime
|
||||||
|
q = db.query(WasteLog).order_by(WasteLog.logged_at.desc())
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(WasteLog.business_day_id == business_day_id)
|
||||||
|
elif from_dt and to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
|
||||||
|
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
|
||||||
|
)
|
||||||
|
entries = q.all()
|
||||||
|
return {"entries": [_waste_out(w) for w in entries]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||||
|
def log_waste(
|
||||||
|
body: WasteLogCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
if body.reason not in VALID_REASONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid reason. Valid: {sorted(VALID_REASONS)}")
|
||||||
|
if body.quantity <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Quantity must be positive")
|
||||||
|
|
||||||
|
product = db.query(Product).filter(Product.id == body.product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
# Snapshot current effective cost
|
||||||
|
unit_cost = _effective_cost(product)
|
||||||
|
total_cost = round(unit_cost * body.quantity, 2) if unit_cost is not None else None
|
||||||
|
|
||||||
|
# Attach to open business day if any
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
|
||||||
|
entry = WasteLog(
|
||||||
|
product_id=body.product_id,
|
||||||
|
quantity=body.quantity,
|
||||||
|
reason=body.reason,
|
||||||
|
reason_notes=body.reason_notes,
|
||||||
|
unit_cost_snapshot=unit_cost,
|
||||||
|
total_cost=total_cost,
|
||||||
|
logged_by_id=user.id,
|
||||||
|
business_day_id=active_day.id if active_day else None,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
return _waste_out(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_waste(
|
||||||
|
entry_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
entry = db.query(WasteLog).filter(WasteLog.id == entry_id).first()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Waste entry not found")
|
||||||
|
|
||||||
|
# Only allow deletion within the same business day
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if active_day and entry.business_day_id != active_day.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Can only delete waste entries from the current open business day")
|
||||||
|
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def waste_summary(
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from datetime import datetime
|
||||||
|
q = db.query(WasteLog)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(WasteLog.business_day_id == business_day_id)
|
||||||
|
elif from_dt and to_dt:
|
||||||
|
q = q.filter(
|
||||||
|
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
|
||||||
|
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
|
||||||
|
)
|
||||||
|
entries = q.all()
|
||||||
|
|
||||||
|
total_items = len(entries)
|
||||||
|
total_cost = round(sum(e.total_cost for e in entries if e.total_cost is not None), 2)
|
||||||
|
uncosted_count = sum(1 for e in entries if e.total_cost is None)
|
||||||
|
|
||||||
|
by_reason: dict = {}
|
||||||
|
by_product: dict = {}
|
||||||
|
for e in entries:
|
||||||
|
r = e.reason
|
||||||
|
if r not in by_reason:
|
||||||
|
by_reason[r] = {"reason": r, "label": REASON_LABELS.get(r, r), "count": 0, "total_cost": 0.0}
|
||||||
|
by_reason[r]["count"] += 1
|
||||||
|
by_reason[r]["total_cost"] += e.total_cost or 0.0
|
||||||
|
|
||||||
|
pid = e.product_id
|
||||||
|
pname = e.product.name if e.product else f"#{pid}"
|
||||||
|
if pid not in by_product:
|
||||||
|
by_product[pid] = {"product_id": pid, "product_name": pname, "qty": 0.0, "total_cost": 0.0}
|
||||||
|
by_product[pid]["qty"] += e.quantity
|
||||||
|
by_product[pid]["total_cost"] += e.total_cost or 0.0
|
||||||
|
|
||||||
|
for v in by_reason.values():
|
||||||
|
v["total_cost"] = round(v["total_cost"], 2)
|
||||||
|
for v in by_product.values():
|
||||||
|
v["total_cost"] = round(v["total_cost"], 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_items": total_items,
|
||||||
|
"total_cost": total_cost,
|
||||||
|
"uncosted_count": uncosted_count,
|
||||||
|
"by_reason": sorted(by_reason.values(), key=lambda x: -x["count"]),
|
||||||
|
"by_product": sorted(by_product.values(), key=lambda x: -x["total_cost"]),
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ class BusinessDayOut(BaseModel):
|
|||||||
closed_at: Optional[UTCDatetime] = None
|
closed_at: Optional[UTCDatetime] = None
|
||||||
closed_by_id: Optional[int] = None
|
closed_by_id: Optional[int] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
# Phase 2E
|
||||||
|
store_opening_cash: Optional[float] = None
|
||||||
|
store_closing_cash: Optional[float] = None
|
||||||
|
store_cash_discrepancy: Optional[float] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -22,3 +26,4 @@ class OpenBusinessDayRequest(BaseModel):
|
|||||||
class CloseBusinessDayRequest(BaseModel):
|
class CloseBusinessDayRequest(BaseModel):
|
||||||
force: bool = False
|
force: bool = False
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
store_closing_cash: Optional[float] = None # Phase 2E: physical cash counted at close
|
||||||
|
|||||||
41
local_backend/schemas/customers.py
Normal file
41
local_backend/schemas/customers.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
nickname: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
nickname: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
nickname: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
created_by_id: int
|
||||||
|
# computed stats (set by router)
|
||||||
|
visit_count: int = 0
|
||||||
|
total_spent: float = 0.0
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AssignCustomerRequest(BaseModel):
|
||||||
|
customer_id: Optional[int] = None # null = unassign
|
||||||
97
local_backend/schemas/expenses.py
Normal file
97
local_backend/schemas/expenses.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contacts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ContactCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str = "supplier"
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expenses ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ExpensePaymentCreate(BaseModel):
|
||||||
|
amount: float
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpensePaymentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
expense_id: int
|
||||||
|
amount: float
|
||||||
|
paid_at: datetime
|
||||||
|
paid_by_id: int
|
||||||
|
paid_by_name: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseCreate(BaseModel):
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
total_amount: float
|
||||||
|
due_date: Optional[datetime] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseUpdate(BaseModel):
|
||||||
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
total_amount: Optional[float] = None
|
||||||
|
due_date: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
total_amount: float
|
||||||
|
paid_amount: float
|
||||||
|
due_amount: float # computed: total - paid
|
||||||
|
status: str # "paid" | "partial" | "due"
|
||||||
|
due_date: Optional[datetime] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
notes: Optional[str] = None
|
||||||
|
payments: List[ExpensePaymentOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
51
local_backend/schemas/notes.py
Normal file
51
local_backend/schemas/notes.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NoteCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
is_pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
is_pinned: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
is_pinned: bool
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TodoCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
priority: str = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class TodoUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
is_done: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodoOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
is_done: bool
|
||||||
|
done_at: Optional[datetime] = None
|
||||||
|
done_by_id: Optional[int] = None
|
||||||
|
done_by_name: Optional[str] = None
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
priority: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -111,7 +111,7 @@ class AuditLogOut(BaseModel):
|
|||||||
|
|
||||||
class OrderOut(BaseModel):
|
class OrderOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
table_id: int
|
table_id: Optional[int] = None
|
||||||
opened_by: int
|
opened_by: int
|
||||||
opened_at: UTCDatetime
|
opened_at: UTCDatetime
|
||||||
status: str
|
status: str
|
||||||
@@ -119,6 +119,16 @@ class OrderOut(BaseModel):
|
|||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
business_day_id: Optional[int] = None
|
business_day_id: Optional[int] = None
|
||||||
|
# Xenia Connect — online order fields
|
||||||
|
source: str = "pos"
|
||||||
|
online_order_ref: Optional[str] = None
|
||||||
|
online_order_cloud_id: Optional[int] = None
|
||||||
|
online_status: Optional[str] = None
|
||||||
|
online_customer_name: Optional[str] = None
|
||||||
|
online_customer_phone: Optional[str] = None
|
||||||
|
online_customer_address: Optional[str] = None
|
||||||
|
online_customer_notes: Optional[str] = None
|
||||||
|
online_order_type: Optional[str] = None
|
||||||
items: List[OrderItemOut] = []
|
items: List[OrderItemOut] = []
|
||||||
waiters: List[OrderWaiterOut] = []
|
waiters: List[OrderWaiterOut] = []
|
||||||
audit_logs: List[AuditLogOut] = []
|
audit_logs: List[AuditLogOut] = []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class PrinterBase(BaseModel):
|
|||||||
port: int = 9100
|
port: int = 9100
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
protocol: str = "escpos_tcp"
|
protocol: str = "escpos_tcp"
|
||||||
|
line_width: int = 48
|
||||||
|
|
||||||
|
|
||||||
class PrinterCreate(PrinterBase):
|
class PrinterCreate(PrinterBase):
|
||||||
@@ -23,6 +24,7 @@ class PrinterUpdate(BaseModel):
|
|||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
protocol: Optional[str] = None
|
protocol: Optional[str] = None
|
||||||
|
line_width: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class PrinterOut(PrinterBase):
|
class PrinterOut(PrinterBase):
|
||||||
|
|||||||
@@ -255,14 +255,31 @@ class PreferenceSetOut(BaseModel):
|
|||||||
|
|
||||||
# ── Products ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CostBreakdownItem(BaseModel):
|
||||||
|
label: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: float
|
base_price: float
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
lifecycle_status: str = "active"
|
lifecycle_status: str = "active"
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
# Xenia Connect — digital menu overrides
|
||||||
|
digital_visible: bool = True
|
||||||
|
digital_available: bool = True
|
||||||
|
digital_name: Optional[str] = None
|
||||||
|
digital_description: Optional[str] = None
|
||||||
|
digital_price: Optional[float] = None
|
||||||
|
digital_discount: Optional[float] = None
|
||||||
|
digital_image_url: Optional[str] = None
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple: Optional[float] = None
|
||||||
|
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
@@ -274,6 +291,7 @@ class ProductCreate(ProductBase):
|
|||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
class ProductUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: Optional[float] = None
|
base_price: Optional[float] = None
|
||||||
is_available: Optional[bool] = None
|
is_available: Optional[bool] = None
|
||||||
@@ -284,6 +302,17 @@ class ProductUpdate(BaseModel):
|
|||||||
options: Optional[List[ProductOptionCreate]] = None
|
options: Optional[List[ProductOptionCreate]] = None
|
||||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||||
|
# Xenia Connect — digital menu overrides
|
||||||
|
digital_visible: Optional[bool] = None
|
||||||
|
digital_available: Optional[bool] = None
|
||||||
|
digital_name: Optional[str] = None
|
||||||
|
digital_description: Optional[str] = None
|
||||||
|
digital_price: Optional[float] = None
|
||||||
|
digital_discount: Optional[float] = None
|
||||||
|
digital_image_url: Optional[str] = None
|
||||||
|
# Phase 2A — cost tracking
|
||||||
|
cost_simple: Optional[float] = None
|
||||||
|
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||||
|
|
||||||
|
|
||||||
class ProductReorderItem(BaseModel):
|
class ProductReorderItem(BaseModel):
|
||||||
@@ -300,3 +329,42 @@ class ProductOut(ProductBase):
|
|||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def parse_cost_breakdown(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, 'cost_breakdown'):
|
||||||
|
raw = data.cost_breakdown
|
||||||
|
parsed = None
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
# Return a plain dict so other validators still see all fields
|
||||||
|
return {
|
||||||
|
'id': data.id,
|
||||||
|
'name': data.name,
|
||||||
|
'description': data.description,
|
||||||
|
'category_id': data.category_id,
|
||||||
|
'base_price': data.base_price,
|
||||||
|
'is_available': data.is_available,
|
||||||
|
'lifecycle_status': data.lifecycle_status,
|
||||||
|
'printer_zone_id': data.printer_zone_id,
|
||||||
|
'sort_order': data.sort_order,
|
||||||
|
'image_url': data.image_url,
|
||||||
|
'digital_visible': bool(data.digital_visible),
|
||||||
|
'digital_available': bool(data.digital_available),
|
||||||
|
'digital_name': data.digital_name,
|
||||||
|
'digital_description': data.digital_description,
|
||||||
|
'digital_price': data.digital_price,
|
||||||
|
'digital_discount': data.digital_discount,
|
||||||
|
'digital_image_url': data.digital_image_url,
|
||||||
|
'cost_simple': data.cost_simple,
|
||||||
|
'cost_breakdown': parsed,
|
||||||
|
'quick_options': list(data.quick_options),
|
||||||
|
'options': list(data.options),
|
||||||
|
'ingredients': list(data.ingredients),
|
||||||
|
'preference_sets': list(data.preference_sets),
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|||||||
93
local_backend/schemas/reservation.py
Normal file
93
local_backend/schemas/reservation.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationCreate(BaseModel):
|
||||||
|
guest_name: str
|
||||||
|
party_size: int
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
reserved_for: datetime
|
||||||
|
table_id: Optional[int] = None
|
||||||
|
source: str = "manager_app"
|
||||||
|
online_customer_id: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("reserved_for", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_timezone(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
elif isinstance(v, datetime):
|
||||||
|
dt = v
|
||||||
|
else:
|
||||||
|
return v
|
||||||
|
# Store as naive local — strip any tz info so SQLite keeps it as-is
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone().replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationUpdate(BaseModel):
|
||||||
|
guest_name: Optional[str] = None
|
||||||
|
party_size: Optional[int] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
reserved_for: Optional[datetime] = None
|
||||||
|
table_id: Optional[int] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("reserved_for", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_timezone(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
elif isinstance(v, datetime):
|
||||||
|
dt = v
|
||||||
|
else:
|
||||||
|
return v
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone().replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationStatusUpdate(BaseModel):
|
||||||
|
status: str # arrived | cancelled | no_show | pending
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
guest_name: str
|
||||||
|
party_size: int
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
reserved_for: datetime
|
||||||
|
table_id: Optional[int] = None
|
||||||
|
status: str
|
||||||
|
source: str
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
online_customer_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
# Denormalized for convenience
|
||||||
|
table_label: Optional[str] = None
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Compact summary embedded in TableOut for the RESERVED badge
|
||||||
|
class ReservationSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
guest_name: str
|
||||||
|
party_size: int
|
||||||
|
reserved_for: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
35
local_backend/schemas/schedule.py
Normal file
35
local_backend/schemas/schedule.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledShiftCreate(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
scheduled_date: date
|
||||||
|
start_time: str # "HH:MM"
|
||||||
|
end_time: str # "HH:MM"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledShiftUpdate(BaseModel):
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
end_time: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledShiftOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
hourly_rate: Optional[float] = None
|
||||||
|
scheduled_date: date
|
||||||
|
start_time: str
|
||||||
|
end_time: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_by_id: int
|
||||||
|
created_at: datetime
|
||||||
|
# computed
|
||||||
|
duration_hours: Optional[float] = None
|
||||||
|
estimated_pay: Optional[float] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -36,3 +36,4 @@ class StartShiftRequest(BaseModel):
|
|||||||
|
|
||||||
class EndShiftRequest(BaseModel):
|
class EndShiftRequest(BaseModel):
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
counted_cash_end: Optional[float] = None # Phase 2E: physical cash waiter hands over
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
MAX_TABLE_NAME_LENGTH = 6
|
MAX_TABLE_NAME_LENGTH = 6
|
||||||
|
|
||||||
@@ -73,6 +74,16 @@ class TableFloorplanUpdate(BaseModel):
|
|||||||
floor_y: float
|
floor_y: float
|
||||||
|
|
||||||
|
|
||||||
|
class UpcomingReservation(BaseModel):
|
||||||
|
id: int
|
||||||
|
guest_name: str
|
||||||
|
party_size: int
|
||||||
|
reserved_for: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class TableOut(BaseModel):
|
class TableOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
number: int
|
number: int
|
||||||
@@ -83,5 +94,6 @@ class TableOut(BaseModel):
|
|||||||
floor_y: Optional[float] = None
|
floor_y: Optional[float] = None
|
||||||
group: Optional[TableGroupOut] = None
|
group: Optional[TableGroupOut] = None
|
||||||
has_active_order: bool = False
|
has_active_order: bool = False
|
||||||
|
upcoming_reservation: Optional[UpcomingReservation] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
63
local_backend/schemas/tabs.py
Normal file
63
local_backend/schemas/tabs.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TabEntryOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
tab_id: int
|
||||||
|
order_id: Optional[int] = None
|
||||||
|
order_item_id: Optional[int] = None
|
||||||
|
amount: float
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_by_id: int
|
||||||
|
created_by_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TabPaymentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
tab_id: int
|
||||||
|
amount: float
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
received_by_id: int
|
||||||
|
received_by_name: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TabOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
customer_id: int
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
customer_phone: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
opened_at: datetime
|
||||||
|
closed_at: Optional[datetime] = None
|
||||||
|
closed_by_id: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
balance: float = 0.0 # computed: sum(entries) - sum(payments)
|
||||||
|
total_charged: float = 0.0
|
||||||
|
total_paid: float = 0.0
|
||||||
|
entries: List[TabEntryOut] = []
|
||||||
|
payments: List[TabPaymentOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TabPayRequest(BaseModel):
|
||||||
|
amount: float
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TabForgiveRequest(BaseModel):
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TabCloseRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
@@ -11,8 +11,11 @@ class UserBase(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
# Phase 2B — payroll
|
||||||
|
hourly_rate: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
@@ -26,6 +29,10 @@ class UserUpdate(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
# Phase 2B — payroll
|
||||||
|
hourly_rate: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class WaiterZoneOut(BaseModel):
|
class WaiterZoneOut(BaseModel):
|
||||||
|
|||||||
37
local_backend/schemas/waste.py
Normal file
37
local_backend/schemas/waste.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
VALID_REASONS = {"kitchen_error", "dropped", "expired", "other"}
|
||||||
|
|
||||||
|
REASON_LABELS = {
|
||||||
|
"kitchen_error": "Λάθος κουζίνας",
|
||||||
|
"dropped": "Έπεσε",
|
||||||
|
"expired": "Έληξε",
|
||||||
|
"other": "Άλλο",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WasteLogCreate(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
quantity: float
|
||||||
|
reason: str
|
||||||
|
reason_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WasteLogOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
quantity: float
|
||||||
|
unit_cost_snapshot: Optional[float] = None
|
||||||
|
total_cost: Optional[float] = None
|
||||||
|
reason: str
|
||||||
|
reason_label: Optional[str] = None
|
||||||
|
reason_notes: Optional[str] = None
|
||||||
|
logged_by_id: int
|
||||||
|
logged_by_name: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
logged_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -24,6 +24,9 @@ import httpx
|
|||||||
from config import settings
|
from config import settings
|
||||||
from middleware.license_check import license_state
|
from middleware.license_check import license_state
|
||||||
|
|
||||||
|
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
||||||
@@ -145,6 +148,7 @@ async def _sync_once():
|
|||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"latest_version": data.get("latest_version"),
|
"latest_version": data.get("latest_version"),
|
||||||
"waiter_domain": data.get("waiter_domain"),
|
"waiter_domain": data.get("waiter_domain"),
|
||||||
|
"site_numeric_id": data.get("site_numeric_id"),
|
||||||
"last_sync": datetime.now(timezone.utc).isoformat(),
|
"last_sync": datetime.now(timezone.utc).isoformat(),
|
||||||
"sync_failed": False,
|
"sync_failed": False,
|
||||||
**expiry_fields,
|
**expiry_fields,
|
||||||
@@ -172,6 +176,175 @@ async def _sync_once():
|
|||||||
license_state.update(expiry_fields)
|
license_state.update(expiry_fields)
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_menu_snapshot():
|
||||||
|
"""Serialize all digital-visible products+categories and POST to cloud."""
|
||||||
|
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.product import Category, Product
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
categories = db.query(Category).filter(Category.parent_id == None).all()
|
||||||
|
payload_categories = []
|
||||||
|
for cat in categories:
|
||||||
|
products = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(
|
||||||
|
Product.category_id == cat.id,
|
||||||
|
Product.digital_visible == 1,
|
||||||
|
Product.lifecycle_status == "active",
|
||||||
|
)
|
||||||
|
.order_by(Product.sort_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
product_list = []
|
||||||
|
for p in products:
|
||||||
|
product_list.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"digital_name": p.digital_name,
|
||||||
|
"digital_description": p.digital_description,
|
||||||
|
"digital_price": p.digital_price,
|
||||||
|
"base_price": p.base_price,
|
||||||
|
"digital_discount": p.digital_discount,
|
||||||
|
"digital_available": bool(p.digital_available),
|
||||||
|
"digital_image_url": p.digital_image_url,
|
||||||
|
"image_url": p.image_url,
|
||||||
|
"quick_options": [
|
||||||
|
{"id": o.id, "name": o.name, "price": o.price}
|
||||||
|
for o in p.quick_options
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if product_list:
|
||||||
|
payload_categories.append({
|
||||||
|
"id": cat.id,
|
||||||
|
"name": cat.name,
|
||||||
|
"sort_order": cat.sort_order,
|
||||||
|
"products": product_list,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
snapshot_json = json.dumps({"categories": payload_categories})
|
||||||
|
|
||||||
|
# Resolve numeric site_id from license state (set by heartbeat response)
|
||||||
|
site_numeric_id = license_state.get("site_numeric_id")
|
||||||
|
if not site_numeric_id:
|
||||||
|
logger.debug("Menu push skipped — site_numeric_id not yet known")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.CLOUD_URL}/api/menu/sync",
|
||||||
|
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||||
|
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Menu snapshot push failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pull_pending_orders():
|
||||||
|
"""Fetch online orders from cloud that haven't been synced to local yet."""
|
||||||
|
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
site_numeric_id = license_state.get("site_numeric_id")
|
||||||
|
if not site_numeric_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.CLOUD_URL}/api/orders/pending/{site_numeric_id}",
|
||||||
|
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
orders = resp.json()
|
||||||
|
|
||||||
|
if not orders:
|
||||||
|
return
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.product import Product
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Use a system user id=1 (first manager/sysadmin) as the opener
|
||||||
|
from models.user import User
|
||||||
|
system_user = db.query(User).filter(User.role.in_(["sysadmin", "manager"])).first()
|
||||||
|
opener_id = system_user.id if system_user else 1
|
||||||
|
|
||||||
|
for cloud_order in orders:
|
||||||
|
# Create local Order row — no table_id for online orders
|
||||||
|
local_order = Order(
|
||||||
|
table_id=None,
|
||||||
|
opened_by=opener_id,
|
||||||
|
status="open",
|
||||||
|
source="online",
|
||||||
|
online_order_ref=cloud_order["public_ref"],
|
||||||
|
online_order_cloud_id=cloud_order["id"],
|
||||||
|
online_status="pending_acceptance",
|
||||||
|
online_customer_name=cloud_order.get("customer_name"),
|
||||||
|
online_customer_phone=cloud_order.get("customer_phone"),
|
||||||
|
online_customer_address=cloud_order.get("customer_address"),
|
||||||
|
online_customer_notes=cloud_order.get("customer_notes"),
|
||||||
|
online_order_type=cloud_order.get("order_type"),
|
||||||
|
)
|
||||||
|
db.add(local_order)
|
||||||
|
db.flush() # get local_order.id
|
||||||
|
|
||||||
|
# Create OrderItem rows
|
||||||
|
items = json.loads(cloud_order.get("items_json", "[]"))
|
||||||
|
for item in items:
|
||||||
|
product = db.query(Product).filter(
|
||||||
|
Product.id == item.get("product_id")
|
||||||
|
).first()
|
||||||
|
db.add(OrderItem(
|
||||||
|
order_id=local_order.id,
|
||||||
|
product_id=item.get("product_id"),
|
||||||
|
added_by=opener_id,
|
||||||
|
quantity=item.get("quantity", 1),
|
||||||
|
unit_price=item.get("unit_price", 0.0),
|
||||||
|
selected_options=json.dumps(item.get("options", [])),
|
||||||
|
status="active",
|
||||||
|
printed=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Mark synced on cloud
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{settings.CLOUD_URL}/api/orders/{cloud_order['id']}/synced",
|
||||||
|
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||||
|
json={"local_order_id": local_order.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Online order %s pulled and created as local order %d",
|
||||||
|
cloud_order["public_ref"], local_order.id)
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Broadcast SSE so the manager dashboard lights up
|
||||||
|
broadcast_sync("online_order_received", {"count": len(orders)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
logger.error("Failed to process pulled orders: %s", e)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Order pull failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
async def _sync_loop():
|
async def _sync_loop():
|
||||||
_load_persisted_state()
|
_load_persisted_state()
|
||||||
while True:
|
while True:
|
||||||
@@ -179,6 +352,125 @@ async def _sync_loop():
|
|||||||
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_stats_snapshot():
|
||||||
|
"""Collect live operational stats and POST to cloud for the remote manager dashboard."""
|
||||||
|
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
site_numeric_id = license_state.get("site_numeric_id")
|
||||||
|
if not site_numeric_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Open tables = orders currently in status open or partially_paid
|
||||||
|
open_tables = db.query(Order).filter(
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
Order.source == "pos",
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Today's completed POS orders (paid or closed today)
|
||||||
|
today_pos_orders = db.query(Order).filter(
|
||||||
|
Order.status.in_(["paid", "closed"]),
|
||||||
|
Order.source == "pos",
|
||||||
|
Order.closed_at >= today_start,
|
||||||
|
).all()
|
||||||
|
today_order_count = len(today_pos_orders)
|
||||||
|
|
||||||
|
# Today's revenue: sum of paid items on those orders
|
||||||
|
today_revenue = 0.0
|
||||||
|
for o in today_pos_orders:
|
||||||
|
for item in o.items:
|
||||||
|
if item.status in ("active", "paid"):
|
||||||
|
today_revenue += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
# Online orders today
|
||||||
|
online_orders_today = db.query(Order).filter(
|
||||||
|
Order.source == "online",
|
||||||
|
Order.opened_at >= today_start,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
online_orders_pending = db.query(Order).filter(
|
||||||
|
Order.source == "online",
|
||||||
|
Order.online_status == "pending_acceptance",
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Active business day
|
||||||
|
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
current_shift = None
|
||||||
|
if open_day:
|
||||||
|
active_shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.business_day_id == open_day.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if active_shift:
|
||||||
|
current_shift = {
|
||||||
|
"waiter_id": active_shift.waiter_id,
|
||||||
|
"started_at": active_shift.started_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
"open_tables": open_tables,
|
||||||
|
"today_revenue": round(today_revenue, 2),
|
||||||
|
"today_orders": today_order_count,
|
||||||
|
"online_orders_pending": online_orders_pending,
|
||||||
|
"online_orders_today": online_orders_today,
|
||||||
|
"current_shift": current_shift,
|
||||||
|
"as_of": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.CLOUD_URL}/api/remote/snapshot",
|
||||||
|
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
|
||||||
|
json={"site_id": site_numeric_id, "snapshot_json": json.dumps(snapshot)},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info("Stats snapshot pushed: %s", snapshot)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Stats snapshot push failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_loop():
|
||||||
|
"""Faster loop: pulls pending online orders every CONNECT_SYNC_INTERVAL_SECONDS.
|
||||||
|
Also pushes menu snapshot and stats every 5 minutes (piggybacking on this loop).
|
||||||
|
On startup, waits for the first heartbeat to populate site_numeric_id then
|
||||||
|
immediately pushes menu + stats without waiting a full cycle."""
|
||||||
|
push_every = max(1, (5 * 60) // ORDER_POLL_INTERVAL)
|
||||||
|
|
||||||
|
# Wait for the heartbeat loop to get site_numeric_id, then do an immediate push
|
||||||
|
for _ in range(30): # wait up to 30 seconds
|
||||||
|
if license_state.get("site_numeric_id"):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await _push_menu_snapshot()
|
||||||
|
await _push_stats_snapshot()
|
||||||
|
|
||||||
|
tick = 0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(ORDER_POLL_INTERVAL)
|
||||||
|
await _pull_pending_orders()
|
||||||
|
tick += 1
|
||||||
|
if tick % push_every == 0:
|
||||||
|
await _push_menu_snapshot()
|
||||||
|
await _push_stats_snapshot()
|
||||||
|
|
||||||
|
|
||||||
async def start_cloud_sync() -> asyncio.Task:
|
async def start_cloud_sync() -> asyncio.Task:
|
||||||
task = asyncio.create_task(_sync_loop())
|
heartbeat_task = asyncio.create_task(_sync_loop())
|
||||||
return task
|
asyncio.create_task(_connect_loop())
|
||||||
|
return heartbeat_task
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ _DIVIDER_CHARS = {
|
|||||||
_PRINT_SETTING_KEYS = [
|
_PRINT_SETTING_KEYS = [
|
||||||
"print.ticket_mode",
|
"print.ticket_mode",
|
||||||
"print.divider_style",
|
"print.divider_style",
|
||||||
|
"print.dot_style",
|
||||||
"print.font_order_number",
|
"print.font_order_number",
|
||||||
"print.font_meta",
|
"print.font_meta",
|
||||||
"print.font_item_name",
|
"print.font_item_name",
|
||||||
@@ -66,11 +67,14 @@ _PRINT_SETTING_KEYS = [
|
|||||||
"print.font_ingredient",
|
"print.font_ingredient",
|
||||||
"print.font_item_note",
|
"print.font_item_note",
|
||||||
"print.font_order_note",
|
"print.font_order_note",
|
||||||
|
"print.beep_on_ticket",
|
||||||
|
"print.beep_pattern",
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRINT_SETTING_DEFAULTS = {
|
_PRINT_SETTING_DEFAULTS = {
|
||||||
"print.ticket_mode": "detailed",
|
"print.ticket_mode": "detailed",
|
||||||
"print.divider_style": "dash",
|
"print.divider_style": "dash",
|
||||||
|
"print.dot_style": "dot_space:0:0",
|
||||||
"print.font_order_number": "48:1:0",
|
"print.font_order_number": "48:1:0",
|
||||||
"print.font_meta": "0:0:0",
|
"print.font_meta": "0:0:0",
|
||||||
"print.font_item_name": "16:1:0",
|
"print.font_item_name": "16:1:0",
|
||||||
@@ -80,6 +84,27 @@ _PRINT_SETTING_DEFAULTS = {
|
|||||||
"print.font_ingredient": "0:0:0",
|
"print.font_ingredient": "0:0:0",
|
||||||
"print.font_item_note": "0:0:0",
|
"print.font_item_note": "0:0:0",
|
||||||
"print.font_order_note": "0:1:0",
|
"print.font_order_note": "0:1:0",
|
||||||
|
"print.beep_on_ticket": "true",
|
||||||
|
"print.beep_pattern": "double",
|
||||||
|
}
|
||||||
|
|
||||||
|
# dot_style → (fill_unit, needs_padding)
|
||||||
|
# fill_unit is repeated to fill available space; needs_padding forces a space
|
||||||
|
# before and after the dot region so it never touches the name or multiplier.
|
||||||
|
_DOT_STYLE_UNITS = {
|
||||||
|
"dot_space": ". ",
|
||||||
|
"dot": ".",
|
||||||
|
"dash_space": "- ",
|
||||||
|
"underscore": "_",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Beep patterns: (n1=on×100ms, n2=off×100ms, n3=count)
|
||||||
|
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
|
||||||
|
_BEEP_PATTERNS = {
|
||||||
|
"single": (2, 2, 1), # one 200ms beep
|
||||||
|
"double": (1, 1, 2), # two short beeps
|
||||||
|
"triple": (1, 1, 3), # three short beeps
|
||||||
|
"long": (5, 2, 1), # one 500ms beep
|
||||||
}
|
}
|
||||||
|
|
||||||
# SIZE byte values (ESC ! base, no bold bit):
|
# SIZE byte values (ESC ! base, no bold bit):
|
||||||
@@ -111,28 +136,81 @@ def _load_print_settings(db: Session) -> dict:
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def _divider(p: Network, style: str = "dash"):
|
def load_divider_style(db: Session) -> str:
|
||||||
|
"""Public helper — returns the configured divider style string."""
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == "print.divider_style").first()
|
||||||
|
return row.value if row else _PRINT_SETTING_DEFAULTS["print.divider_style"]
|
||||||
|
|
||||||
|
|
||||||
|
def _divider(p: Network, style: str = "dash", line_width: int = LINE_WIDTH):
|
||||||
char = _DIVIDER_CHARS.get(style, "-")
|
char = _DIVIDER_CHARS.get(style, "-")
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
if char:
|
if char:
|
||||||
p._raw(_gr(char * LINE_WIDTH + "\n"))
|
p._raw(_gr(char * line_width + "\n"))
|
||||||
else:
|
else:
|
||||||
p._raw(b'\n')
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
|
||||||
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str = "dot_space") -> str:
|
||||||
"""Build a dot-leader line ending with 'xN'.
|
"""Simple single-font dot-leader string — used by receipt and synopsis printers."""
|
||||||
line_width must reflect the effective width at the chosen font size
|
|
||||||
(double-width fonts halve the available char count to 24)."""
|
|
||||||
suffix = f" x{qty}"
|
suffix = f" x{qty}"
|
||||||
available = line_width - len(name) - len(suffix)
|
available = line_width - len(name) - 1 - len(suffix)
|
||||||
if available < 2:
|
if available < 1:
|
||||||
# Name alone is too long — put qty on same line with a single space
|
return f"{name} x{qty}"
|
||||||
return f"{name} {suffix}"
|
unit = _DOT_STYLE_UNITS.get(dot_style, ". ")
|
||||||
dots = (". " * ((available // 2) + 1))[:available]
|
dots = (unit * (available // len(unit) + 1))[:available]
|
||||||
return f"{name} {dots}{suffix}"
|
return f"{name} {dots}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_item_line(
|
||||||
|
p: Network,
|
||||||
|
name: str, qty: int,
|
||||||
|
sz_name: int, b_name: bool,
|
||||||
|
dot_fill: str, dot_sz: int, dot_bold: bool,
|
||||||
|
line_width: int = LINE_WIDTH,
|
||||||
|
):
|
||||||
|
"""Print one item line as three segments with independent fonts:
|
||||||
|
[name font] name· [dot font] ····· [name font] ·xN\\n
|
||||||
|
Column widths are computed in physical printer columns (each double-width
|
||||||
|
char occupies 2 columns) so mixed font sizes stay aligned."""
|
||||||
|
suffix = f" x{qty}"
|
||||||
|
|
||||||
|
# Physical column width of each character under each font
|
||||||
|
name_char_w = 2 if sz_name in (32, 48) else 1
|
||||||
|
dot_char_w = 2 if dot_sz in (32, 48) else 1
|
||||||
|
|
||||||
|
# Columns consumed by name (with trailing space) and suffix
|
||||||
|
name_cols = (len(name) + 1) * name_char_w # +1 for the mandatory gap space
|
||||||
|
suffix_cols = len(suffix) * name_char_w
|
||||||
|
|
||||||
|
available_cols = line_width - name_cols - suffix_cols
|
||||||
|
if available_cols < dot_char_w:
|
||||||
|
# No room for even one dot — fall back to plain line in name font
|
||||||
|
_apply_font(p, sz_name, b_name)
|
||||||
|
_raw_text(p, f"{name} x{qty}\n")
|
||||||
|
_reset_font(p)
|
||||||
|
return
|
||||||
|
|
||||||
|
# How many dot characters fit in the available columns
|
||||||
|
n_dots = available_cols // dot_char_w
|
||||||
|
unit = _DOT_STYLE_UNITS.get(dot_fill, ". ")
|
||||||
|
dots = (unit * (n_dots // len(unit) + 1))[:n_dots]
|
||||||
|
|
||||||
|
# Emit: [name] [space] in name font
|
||||||
|
_apply_font(p, sz_name, b_name)
|
||||||
|
_raw_text(p, name + " ")
|
||||||
|
|
||||||
|
# Emit: dots in dot font
|
||||||
|
_apply_font(p, dot_sz, dot_bold)
|
||||||
|
_raw_text(p, dots)
|
||||||
|
|
||||||
|
# Emit: [space][xN]\n back in name font
|
||||||
|
_apply_font(p, sz_name, b_name)
|
||||||
|
_raw_text(p, suffix + "\n")
|
||||||
|
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
|
||||||
def _apply_font(p: Network, size: int, bold: bool):
|
def _apply_font(p: Network, size: int, bold: bool):
|
||||||
p._raw(bytes([0x1b, 0x21, size]))
|
p._raw(bytes([0x1b, 0x21, size]))
|
||||||
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||||
@@ -179,6 +257,21 @@ def is_spoof_mode() -> bool:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int) -> Tuple[bool, str]:
|
||||||
|
"""Send a standalone beep-only job. n1=on-time×100ms, n2=off-time×100ms, n3=count."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping test beep")
|
||||||
|
return True, ""
|
||||||
|
try:
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
p._raw(bytes([0x1b, 0x07, n1, n2, n3]))
|
||||||
|
p.close()
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Test beep failed for %s:%s — %s", ip, port, e)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping test print for %s", name)
|
logger.info("Spoof printing ON — dropping test print for %s", name)
|
||||||
@@ -200,7 +293,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
|
def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LINE_WIDTH) -> Tuple[bool, str]:
|
||||||
"""Print a fake order using the current font/layout settings — for settings preview."""
|
"""Print a fake order using the current font/layout settings — for settings preview."""
|
||||||
if _is_spoof_mode(db):
|
if _is_spoof_mode(db):
|
||||||
logger.info("Spoof printing ON — dropping test order print")
|
logger.info("Spoof printing ON — dropping test order print")
|
||||||
@@ -315,7 +408,7 @@ def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
|
_print_kitchen_ticket(p, _Order(), items, _PatchedDB(), line_width)
|
||||||
p.close()
|
p.close()
|
||||||
return True, ""
|
return True, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -387,7 +480,7 @@ def _parse_options(item: OrderItem) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
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, line_width: int = LINE_WIDTH):
|
||||||
cfg = _load_print_settings(db)
|
cfg = _load_print_settings(db)
|
||||||
mode = cfg.get("print.ticket_mode", "detailed")
|
mode = cfg.get("print.ticket_mode", "detailed")
|
||||||
div = cfg.get("print.divider_style", "dash")
|
div = cfg.get("print.divider_style", "dash")
|
||||||
@@ -415,24 +508,28 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
||||||
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# ── DETAILED header ──────────────────────────────────────────────────────
|
# ── DETAILED header ──────────────────────────────────────────────────────
|
||||||
else:
|
else:
|
||||||
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
||||||
align=b'\x1b\x61\x01')
|
align=b'\x1b\x61\x01')
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
_apply_font(p, sz_meta, b_meta)
|
_apply_font(p, sz_meta, b_meta)
|
||||||
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
|
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
|
||||||
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
||||||
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# ── Items ────────────────────────────────────────────────────────────────
|
# ── Items ────────────────────────────────────────────────────────────────
|
||||||
# Double-width fonts halve the effective character width
|
# Dot-leader style: "STYLE:SIZE:BOLD"
|
||||||
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
|
raw_dot = cfg.get("print.dot_style", "dot_space:0:0")
|
||||||
|
dot_parts = raw_dot.split(":")
|
||||||
|
dot_fill = dot_parts[0] if dot_parts[0] in _DOT_STYLE_UNITS else "dot_space"
|
||||||
|
dot_sz = int(dot_parts[1]) if len(dot_parts) > 1 and dot_parts[1].isdigit() else 0
|
||||||
|
dot_bold = (dot_parts[2] == "1") if len(dot_parts) > 2 else False
|
||||||
|
|
||||||
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()
|
||||||
@@ -440,9 +537,12 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
item_name = raw_name.upper() if c_item else raw_name
|
item_name = raw_name.upper() if c_item else raw_name
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
_apply_font(p, sz_item, b_item)
|
_print_item_line(
|
||||||
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
p, item_name, item.quantity,
|
||||||
_reset_font(p)
|
sz_item, b_item,
|
||||||
|
dot_fill, dot_sz, dot_bold,
|
||||||
|
line_width,
|
||||||
|
)
|
||||||
|
|
||||||
opts = _parse_options(item)
|
opts = _parse_options(item)
|
||||||
|
|
||||||
@@ -544,7 +644,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
if not compact:
|
if not compact:
|
||||||
p._raw(b'\n')
|
p._raw(b'\n')
|
||||||
|
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# Order-level notes
|
# Order-level notes
|
||||||
if order.notes:
|
if order.notes:
|
||||||
@@ -553,7 +653,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
_raw_text(p, f"Σημ: {note_text}\n")
|
_raw_text(p, f"Σημ: {note_text}\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
if not compact:
|
if not compact:
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# Footer (detailed only)
|
# Footer (detailed only)
|
||||||
if not compact:
|
if not compact:
|
||||||
@@ -563,6 +663,22 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
|
|
||||||
|
# Beep before cut so the buzzer fires as the paper advances
|
||||||
|
beep_enabled = cfg.get("print.beep_on_ticket", "true") == "true"
|
||||||
|
if beep_enabled:
|
||||||
|
pattern = cfg.get("print.beep_pattern", "double")
|
||||||
|
if pattern.startswith("custom:"):
|
||||||
|
# custom:n1:n2:n3
|
||||||
|
try:
|
||||||
|
_, n1s, n2s, n3s = pattern.split(":")
|
||||||
|
beep_bytes = (int(n1s), int(n2s), int(n3s))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
beep_bytes = _BEEP_PATTERNS["double"]
|
||||||
|
else:
|
||||||
|
beep_bytes = _BEEP_PATTERNS.get(pattern, _BEEP_PATTERNS["double"])
|
||||||
|
p._raw(bytes([0x1b, 0x07, beep_bytes[0], beep_bytes[1], beep_bytes[2]]))
|
||||||
|
|
||||||
p.cut()
|
p.cut()
|
||||||
|
|
||||||
|
|
||||||
@@ -614,7 +730,7 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
value = f"{od['total']:.2f}e"
|
value = f"{od['total']:.2f}e"
|
||||||
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
||||||
prefix = f"{times_part} - {table}"
|
prefix = f"{times_part} - {table}"
|
||||||
gap = LINE_WIDTH - len(prefix) - len(value)
|
gap = LINE_WIDTH - len(prefix) - len(value) # reports always use default width
|
||||||
if gap < 3:
|
if gap < 3:
|
||||||
line = f"{prefix} {value}"
|
line = f"{prefix} {value}"
|
||||||
else:
|
else:
|
||||||
@@ -629,61 +745,145 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
logger.error("print_waiter_report failed for %s:%s — %s", ip, port, e)
|
logger.error("print_waiter_report failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _dot_leader_line(left: str, right: str, lw: int) -> str:
|
||||||
|
"""Build a plain dot-leader line: 'left . . . . right' fitting in lw chars."""
|
||||||
|
gap = lw - len(left) - len(right)
|
||||||
|
if gap < 2:
|
||||||
|
return f"{left} {right}"
|
||||||
|
dots = (". " * (gap // 2 + 1))[:gap]
|
||||||
|
return f"{left}{dots}{right}"
|
||||||
|
|
||||||
|
|
||||||
|
def _printer_report_block(p, block: dict, mode: str, lw: int, div: str):
|
||||||
|
"""Print a single printer's block onto an already-open printer connection."""
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Εκτυπωτης: {block['printer_name']}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
# Stats in double-height (no bold)
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Εκτυπωσεις: {block['print_jobs']}\n")
|
||||||
|
_raw_text(p, f"Παραγγελιες: {block['orders']}\n")
|
||||||
|
_raw_text(p, f"Αντικειμενα: {block['items']}\n")
|
||||||
|
_raw_text(p, f"Αξια: {block['total']:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
if mode in ("orders", "detailed") and block.get("order_data"):
|
||||||
|
_divider(p, div, lw)
|
||||||
|
for od in block["order_data"]:
|
||||||
|
# Order header line: plain font, dot leader
|
||||||
|
prefix = f"#{od['id']} {od['table']}"
|
||||||
|
value = f"{od['total']:.2f}e"
|
||||||
|
header_line = _dot_leader_line(prefix, value, lw)
|
||||||
|
# No bold — plain normal font
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
_raw_text(p, header_line + "\n")
|
||||||
|
|
||||||
|
if mode == "detailed":
|
||||||
|
for item in od.get("items", []):
|
||||||
|
# " qty x Name . . . . total_price"
|
||||||
|
qty = item["quantity"]
|
||||||
|
name = item["name"]
|
||||||
|
total_val = item.get("total", 0.0)
|
||||||
|
left = f" {qty}x {name}"
|
||||||
|
right = f"{total_val:.2f}e"
|
||||||
|
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _printer_item_breakdown(p, blocks: list, lw: int, div: str):
|
||||||
|
"""Print the product analysis section (combined across all blocks)."""
|
||||||
|
# Merge breakdown across all printer blocks
|
||||||
|
combined: dict = {}
|
||||||
|
for block in blocks:
|
||||||
|
for entry in block.get("item_breakdown", []):
|
||||||
|
name = entry["name"]
|
||||||
|
if name not in combined:
|
||||||
|
combined[name] = {"qty": 0, "value": 0.0}
|
||||||
|
combined[name]["qty"] += entry["qty"]
|
||||||
|
combined[name]["value"] = round(combined[name]["value"] + entry["value"], 2)
|
||||||
|
|
||||||
|
if not combined:
|
||||||
|
return
|
||||||
|
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, "ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
sorted_items = sorted(combined.items(), key=lambda x: x[1]["qty"], reverse=True)
|
||||||
|
for name, data in sorted_items:
|
||||||
|
line = _dot_leader_line(name, str(data["qty"]), lw)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
|
||||||
|
|
||||||
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
||||||
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
"""Print a printer history report. report contains printer_blocks list."""
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping printer report print")
|
logger.info("Spoof printing ON — dropping printer report print")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
lw = report.get("line_width", LINE_WIDTH)
|
||||||
|
div = report.get("div_style", "dash")
|
||||||
|
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
|
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
|
period = report.get("period_label", "")
|
||||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
if report.get("period_is_workday"):
|
||||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
_raw_text(p, f"Εργασιμη: {period}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"Περιοδος: {period}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
|
||||||
|
|
||||||
|
blocks = report.get("printer_blocks", [])
|
||||||
|
for block in blocks:
|
||||||
|
_printer_report_block(p, block, mode, lw, div)
|
||||||
|
|
||||||
|
# Grand total footer
|
||||||
|
if len(blocks) > 1:
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
|
_raw_text(p, "ΣΥΝΟΛΟ ΟΛΩΝ\n")
|
||||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
|
||||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
total_jobs = sum(b["print_jobs"] for b in blocks)
|
||||||
|
total_orders = sum(b["orders"] for b in blocks)
|
||||||
|
total_items = sum(b["items"] for b in blocks)
|
||||||
|
total_value = sum(b["total"] for b in blocks)
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Εκτυπωσεις: {total_jobs}\n")
|
||||||
|
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
|
||||||
|
_raw_text(p, f"Αντικειμενα: {total_items}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
_raw_text(p, f"ΣΥΝΟΛΟ: {total_value:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
elif len(blocks) == 1:
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"ΣΥΝΟΛΟ: {blocks[0]['total']:.2f}e\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
if mode == "extensive" and report.get("order_data"):
|
# Optional product breakdown section
|
||||||
_divider(p)
|
if report.get("item_breakdown") and blocks:
|
||||||
p._raw(b'\x1b\x61\x00')
|
_printer_item_breakdown(p, blocks, lw, div)
|
||||||
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
|
|
||||||
_divider(p)
|
|
||||||
for od in report["order_data"]:
|
|
||||||
# Header line: "HH:MM - TABLE . . . . 9.99e"
|
|
||||||
prefix = f"{od['time']} - {od['table']}"
|
|
||||||
value = f"{od['total']:.2f}e"
|
|
||||||
gap = LINE_WIDTH - len(prefix) - len(value)
|
|
||||||
if gap < 3:
|
|
||||||
header_line = f"{prefix} {value}"
|
|
||||||
else:
|
|
||||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
|
||||||
header_line = f"{prefix}{dots}{value}"
|
|
||||||
p._raw(b'\x1b\x45\x01')
|
|
||||||
_raw_text(p, header_line + "\n")
|
|
||||||
p._raw(b'\x1b\x45\x00')
|
|
||||||
# Indented items
|
|
||||||
for item in od.get("items", []):
|
|
||||||
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
|
|
||||||
|
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
p.cut()
|
p.cut()
|
||||||
@@ -692,7 +892,7 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
logger.error("print_printer_report failed for %s:%s — %s", ip, port, e)
|
logger.error("print_printer_report failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
def print_order_receipt(ip: str, port: int, receipt: dict):
|
def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LINE_WIDTH):
|
||||||
"""Print a manager-triggered order receipt."""
|
"""Print a manager-triggered order receipt."""
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping order receipt print")
|
logger.info("Spoof printing ON — dropping order receipt print")
|
||||||
@@ -704,7 +904,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
|
|||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
@@ -714,21 +914,21 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
|
|||||||
if receipt.get("closed_at"):
|
if receipt.get("closed_at"):
|
||||||
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
for item in receipt.get("items", []):
|
for item in receipt.get("items", []):
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
|
_raw_text(p, _item_line(item["name"], item["quantity"], line_width=line_width) + "\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||||
|
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
if receipt.get("notes"):
|
if receipt.get("notes"):
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Σημ: {receipt['notes']}\n")
|
_raw_text(p, f"Σημ: {receipt['notes']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
@@ -742,7 +942,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
|
|||||||
logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e)
|
logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = LINE_WIDTH):
|
||||||
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping order synopsis print")
|
logger.info("Spoof printing ON — dropping order synopsis print")
|
||||||
@@ -754,7 +954,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
|||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
@@ -762,7 +962,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
|||||||
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
|
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
|
||||||
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
|
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
|
||||||
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
|
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
|
||||||
@@ -772,18 +972,18 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
|||||||
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
|
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
for item in active_items:
|
for item in active_items:
|
||||||
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
|
||||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
if paid_items:
|
if paid_items:
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
for item in paid_items:
|
for item in paid_items:
|
||||||
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
|
||||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
@@ -801,6 +1001,189 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
|||||||
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analytical report prints (products / categories / tables) ─────────────────
|
||||||
|
|
||||||
|
def print_products_report(ip: str, port: int, report: dict):
|
||||||
|
"""Print a product sales report. mode='smart'(sold only) or 'full'(all products)."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping products report print")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lw = report.get("line_width", LINE_WIDTH)
|
||||||
|
div = report.get("div_style", "dash")
|
||||||
|
mode = report.get("mode", "smart")
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
title = "ΑΝΑΦΟΡΑ ΠΡΟΙΟΝΤΩΝ" if mode == "smart" else "ΠΛΗΡΗΣ ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ"
|
||||||
|
_raw_text(p, title + "\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
period = report.get("period_label", "")
|
||||||
|
if report.get("period_is_workday"):
|
||||||
|
_raw_text(p, f"Εργασιμη: {period}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"Περιοδος: {period}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
items = report.get("items", [])
|
||||||
|
# Only items with sales contribute to pct denominators
|
||||||
|
sold_items = [x for x in items if x["qty"] > 0]
|
||||||
|
total_qty = sum(x["qty"] for x in sold_items)
|
||||||
|
total_rev = sum(x["revenue"] for x in sold_items)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item["qty"] == 0 and mode == "smart":
|
||||||
|
continue
|
||||||
|
left = item["name"]
|
||||||
|
right = f"{item['qty']} τεμ."
|
||||||
|
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
|
||||||
|
pct_rev = round(item["revenue"] / total_rev * 100, 1) if total_rev else 0
|
||||||
|
pct_qty = round(item["qty"] / total_qty * 100, 1) if total_qty else 0
|
||||||
|
_raw_text(p, f" {item['revenue']:.2f}e | {pct_rev}% εσ. {pct_qty}% τεμ.\n")
|
||||||
|
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("print_products_report failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
def print_categories_report(ip: str, port: int, report: dict):
|
||||||
|
"""Print a category sales report."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping categories report print")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lw = report.get("line_width", LINE_WIDTH)
|
||||||
|
div = report.get("div_style", "dash")
|
||||||
|
mode = report.get("mode", "smart")
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "ΑΝΑΦΟΡΑ ΚΑΤΗΓΟΡΙΩΝ\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
period = report.get("period_label", "")
|
||||||
|
if report.get("period_is_workday"):
|
||||||
|
_raw_text(p, f"Εργασιμη: {period}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"Περιοδος: {period}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
categories = report.get("categories", [])
|
||||||
|
total_rev = sum(c["revenue"] for c in categories)
|
||||||
|
total_qty = sum(c["units_sold"] for c in categories)
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"{cat['name']}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_raw_text(p, _dot_leader_line("Τεμαχια", str(cat["units_sold"]), lw) + "\n")
|
||||||
|
_raw_text(p, _dot_leader_line("Εσοδα", f"{cat['revenue']:.2f}e", lw) + "\n")
|
||||||
|
pct_rev = cat.get("pct_rev", cat.get("pct", 0))
|
||||||
|
pct_qty = cat.get("pct_qty", 0)
|
||||||
|
_raw_text(p, _dot_leader_line("% Εσοδων", f"{pct_rev}%", lw) + "\n")
|
||||||
|
_raw_text(p, _dot_leader_line("% Τεμαχιων", f"{pct_qty}%", lw) + "\n")
|
||||||
|
if mode == "full" and cat.get("products"):
|
||||||
|
for prod in cat["products"]:
|
||||||
|
left = f" {prod['name']}"
|
||||||
|
right = f"{prod['qty']} | {prod.get('pct_rev', 0)}%E {prod.get('pct_qty', 0)}%T"
|
||||||
|
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
|
||||||
|
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("print_categories_report failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
def print_tables_report(ip: str, port: int, report: dict):
|
||||||
|
"""Print a table performance report."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping tables report print")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lw = report.get("line_width", LINE_WIDTH)
|
||||||
|
div = report.get("div_style", "dash")
|
||||||
|
mode = report.get("mode", "smart")
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "ΑΝΑΦΟΡΑ ΤΡΑΠΕΖΙΩΝ\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
period = report.get("period_label", "")
|
||||||
|
if report.get("period_is_workday"):
|
||||||
|
_raw_text(p, f"Εργασιμη: {period}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"Περιοδος: {period}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, lw)
|
||||||
|
|
||||||
|
tables = report.get("tables", [])
|
||||||
|
total_rev = sum(t["revenue"] for t in tables)
|
||||||
|
total_orders = sum(t["order_count"] for t in tables)
|
||||||
|
|
||||||
|
for t in tables:
|
||||||
|
left = t["name"]
|
||||||
|
right = f"{t['revenue']:.2f}e"
|
||||||
|
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
|
||||||
|
if mode == "full":
|
||||||
|
avg = t["revenue"] / t["order_count"] if t["order_count"] else 0
|
||||||
|
_raw_text(p, f" {t['order_count']} παρ. / μ.ο. {avg:.2f}e\n")
|
||||||
|
if t.get("avg_duration_minutes") is not None:
|
||||||
|
_raw_text(p, f" Μ. διαρκεια: {t['avg_duration_minutes']:.0f} λεπτα\n")
|
||||||
|
|
||||||
|
_divider(p, div, lw)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("print_tables_report failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
# ── Routing logic ────────────────────────────────────────────────────────────
|
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def route_and_print(order_id: int, item_ids: List[int]):
|
def route_and_print(order_id: int, item_ids: List[int]):
|
||||||
@@ -874,7 +1257,7 @@ def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List
|
|||||||
error_msg = None
|
error_msg = None
|
||||||
try:
|
try:
|
||||||
p = _get_printer(printer.ip_address, printer.port)
|
p = _get_printer(printer.ip_address, printer.port)
|
||||||
_print_kitchen_ticket(p, order, zone_items, db)
|
_print_kitchen_ticket(p, order, zone_items, db, printer.line_width)
|
||||||
p.close()
|
p.close()
|
||||||
success = True
|
success = True
|
||||||
for item in zone_items:
|
for item in zone_items:
|
||||||
|
|||||||
48
local_backend/services/reservation_tasks.py
Normal file
48
local_backend/services/reservation_tasks.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Background task: auto-mark overdue pending reservations as no_show."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Grace period after reserved_for before a pending reservation becomes no_show
|
||||||
|
NO_SHOW_GRACE_MINUTES = 30
|
||||||
|
|
||||||
|
# How often the checker runs
|
||||||
|
CHECK_INTERVAL_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_no_show_checker():
|
||||||
|
# Import here to avoid circular imports at module load time
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.reservation import Reservation
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=NO_SHOW_GRACE_MINUTES)
|
||||||
|
overdue = (
|
||||||
|
db.query(Reservation)
|
||||||
|
.filter(
|
||||||
|
Reservation.status == "pending",
|
||||||
|
Reservation.reserved_for < cutoff,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if overdue:
|
||||||
|
for r in overdue:
|
||||||
|
r.status = "no_show"
|
||||||
|
r.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
logger.info("no_show_checker: marked %d reservation(s) as no_show", len(overdue))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("no_show_checker: unexpected error")
|
||||||
|
|
||||||
|
|
||||||
|
async def start_reservation_tasks() -> asyncio.Task:
|
||||||
|
task = asyncio.create_task(_run_no_show_checker())
|
||||||
|
return task
|
||||||
119
local_backend/wipe_database.py
Normal file
119
local_backend/wipe_database.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Wipes the POS database completely and re-applies the schema + default seed data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python wipe_database.py
|
||||||
|
|
||||||
|
Safe to run even if the backend is stopped. Does NOT delete the .db file —
|
||||||
|
it drops all tables and recreates them so the file path stays intact.
|
||||||
|
After running this, run demo_seed.py to populate with demo data.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ── Confirmation prompt ───────────────────────────────────────────────────────
|
||||||
|
print("=" * 60)
|
||||||
|
print(" WARNING: This will PERMANENTLY DELETE all data in the")
|
||||||
|
print(" POS database including orders, products, users, etc.")
|
||||||
|
print("=" * 60)
|
||||||
|
answer = input("\nType YES to confirm: ").strip()
|
||||||
|
if answer != "YES":
|
||||||
|
print("Aborted.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# ── Imports (after confirmation, so we don't waste time if user bails) ────────
|
||||||
|
from sqlalchemy import text, inspect
|
||||||
|
from database import engine, Base, SessionLocal
|
||||||
|
|
||||||
|
# Import all models so Base.metadata knows about every table
|
||||||
|
import models.user # noqa: F401
|
||||||
|
import models.table # noqa: F401
|
||||||
|
import models.printer # noqa: F401
|
||||||
|
import models.product # noqa: F401
|
||||||
|
import models.order # noqa: F401
|
||||||
|
import models.business_day # noqa: F401
|
||||||
|
import models.shift # noqa: F401
|
||||||
|
import models.settings # noqa: F401
|
||||||
|
import models.flag # noqa: F401
|
||||||
|
import models.message # noqa: F401
|
||||||
|
|
||||||
|
print("\nDropping all tables...")
|
||||||
|
|
||||||
|
# SQLite doesn't support DROP TABLE ... CASCADE, so we disable FK checks,
|
||||||
|
# drop every table we know about, then re-enable them.
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("PRAGMA foreign_keys = OFF"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Reflect the actual DB to catch any tables we might have missed in models
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Drop in reverse dependency order using reflected metadata
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for table_name in existing_tables:
|
||||||
|
conn.execute(text(f"DROP TABLE IF EXISTS [{table_name}]"))
|
||||||
|
print(f" dropped: {table_name}")
|
||||||
|
conn.execute(text("PRAGMA foreign_keys = ON"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("\nRecreating schema...")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Re-run the same migration/seed SQL from main.py so default settings,
|
||||||
|
# flags, and quick message templates are present after the wipe.
|
||||||
|
print("Applying seed migrations...")
|
||||||
|
|
||||||
|
seed_migrations = [
|
||||||
|
# pos_settings defaults
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
|
||||||
|
# Default table flag definitions
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '⭐', '#8b5cf6', 5)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
|
||||||
|
# Default quick message templates
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
|
||||||
|
]
|
||||||
|
|
||||||
|
for sql in seed_migrations:
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(sql))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [skip] {e}")
|
||||||
|
|
||||||
|
print("\nDone. Database is clean and ready.")
|
||||||
|
print("Run python demo_seed.py to populate with demo data.")
|
||||||
@@ -10,6 +10,15 @@ import OrderDetailPage from './pages/OrderDetailPage'
|
|||||||
import ManagementPage from './pages/ManagementPage'
|
import ManagementPage from './pages/ManagementPage'
|
||||||
import ReportsPage from './pages/reports/ReportsPage'
|
import ReportsPage from './pages/reports/ReportsPage'
|
||||||
import SettingsPage from './pages/Settings/SettingsPage'
|
import SettingsPage from './pages/Settings/SettingsPage'
|
||||||
|
import OnlineOrdersPage from './pages/OnlineOrdersPage'
|
||||||
|
import NotesPage from './pages/NotesPage'
|
||||||
|
import ContactsPage from './pages/ContactsPage'
|
||||||
|
import ExpensesPage from './pages/ExpensesPage'
|
||||||
|
import CustomersPage from './pages/CustomersPage'
|
||||||
|
import TabsPage from './pages/TabsPage'
|
||||||
|
import WastePage from './pages/WastePage'
|
||||||
|
import KdsPage from './pages/KdsPage'
|
||||||
|
import SchedulePage from './pages/SchedulePage'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -80,6 +89,15 @@ export default function App() {
|
|||||||
<Route path="tables" element={<TablesPage />} />
|
<Route path="tables" element={<TablesPage />} />
|
||||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||||
<Route path="management" element={<ManagementPage />} />
|
<Route path="management" element={<ManagementPage />} />
|
||||||
|
<Route path="notes" element={<NotesPage />} />
|
||||||
|
<Route path="contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="expenses" element={<ExpensesPage />} />
|
||||||
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="tabs" element={<TabsPage />} />
|
||||||
|
<Route path="waste" element={<WastePage />} />
|
||||||
|
<Route path="kds" element={<KdsPage />} />
|
||||||
|
<Route path="schedule" element={<SchedulePage />} />
|
||||||
|
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -23,3 +23,18 @@ client.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default client
|
export default client
|
||||||
|
|
||||||
|
// ── Online Orders (Xenia Connect) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getIncomingOrders = () => client.get('/api/connect/orders/incoming')
|
||||||
|
export const getActiveOrders = () => client.get('/api/connect/orders?status=active')
|
||||||
|
export const getOrderHistory = () => client.get('/api/connect/orders?status=history')
|
||||||
|
|
||||||
|
export const acceptOnlineOrder = (id) =>
|
||||||
|
client.post(`/api/connect/orders/${id}/accept`)
|
||||||
|
|
||||||
|
export const rejectOnlineOrder = (id, reason) =>
|
||||||
|
client.post(`/api/connect/orders/${id}/reject`, { reason })
|
||||||
|
|
||||||
|
export const updateOnlineOrderStatus = (id, status) =>
|
||||||
|
client.post(`/api/connect/orders/${id}/status`, { status })
|
||||||
|
|||||||
@@ -1,17 +1,60 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
|
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat, CalendarDays } from 'lucide-react'
|
||||||
|
import { getIncomingOrders } from '../api/client'
|
||||||
|
import { isFeatureEnabled } from '../hooks/usePhase2Features'
|
||||||
|
|
||||||
const NAV = [
|
// Phase 2 feature IDs mapped to their sidebar routes (must match usePhase2Features defs)
|
||||||
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
const PHASE2_ROUTES = new Set(['/notes', '/expenses', '/contacts', '/customers', '/tabs', '/waste', '/kds', '/schedule'])
|
||||||
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
|
||||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
|
||||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
|
||||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [, featTick] = useState(0)
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPending() {
|
||||||
|
try {
|
||||||
|
const { data } = await getIncomingOrders()
|
||||||
|
setPendingCount(data.length)
|
||||||
|
} catch { /* silently ignore — sidebar badge is non-critical */ }
|
||||||
|
}
|
||||||
|
fetchPending()
|
||||||
|
pollRef.current = setInterval(fetchPending, 20_000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Re-render when feature flags change
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => featTick(n => n + 1)
|
||||||
|
window.addEventListener('phase2features', handler)
|
||||||
|
window.addEventListener('storage', handler)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('phase2features', handler)
|
||||||
|
window.removeEventListener('storage', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ALL_NAV = [
|
||||||
|
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
||||||
|
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||||
|
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount, phase2: 'online-orders' },
|
||||||
|
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||||
|
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||||
|
{ to: '/notes', icon: NotebookPen, label: 'Σημειώσεις', phase2: 'notes' },
|
||||||
|
{ to: '/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' },
|
||||||
|
{ to: '/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' },
|
||||||
|
{ to: '/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' },
|
||||||
|
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες', phase2: 'tabs' },
|
||||||
|
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα', phase2: 'waste' },
|
||||||
|
{ to: '/kds', icon: ChefHat, label: 'KDS', phase2: 'kds' },
|
||||||
|
{ to: '/schedule', icon: CalendarDays, label: 'Πρόγραμμα', phase2: 'schedule' },
|
||||||
|
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Filter out disabled Phase 2 entries
|
||||||
|
const NAV = ALL_NAV.filter(item => !item.phase2 || isFeatureEnabled(item.phase2))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||||
@@ -28,7 +71,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||||
{NAV.map(({ to, icon: Icon, label }) => (
|
{NAV.map(({ to, icon: Icon, label, badge }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -37,7 +80,14 @@ export default function Sidebar() {
|
|||||||
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon size={20} className="shrink-0" />
|
<div className="relative shrink-0">
|
||||||
|
<Icon size={20} />
|
||||||
|
{badge > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center leading-none">
|
||||||
|
{badge > 9 ? '9+' : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!collapsed && <span className="text-sm">{label}</span>}
|
{!collapsed && <span className="text-sm">{label}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
906
manager_dashboard/src/components/WorkdaySummaryModal.jsx
Normal file
906
manager_dashboard/src/components/WorkdaySummaryModal.jsx
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadge(role) {
|
||||||
|
if (role === 'manager' || role === 'sysadmin') {
|
||||||
|
return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' }
|
||||||
|
}
|
||||||
|
return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// All payment methods with their display config
|
||||||
|
const PAY_METHODS = [
|
||||||
|
{ key: 'cash', label: 'Μετρητά', color: '#16a34a', bg: '#dcfce7' },
|
||||||
|
{ key: 'card', label: 'Κάρτα', color: '#2563eb', bg: '#dbeafe' },
|
||||||
|
{ key: 'transfer', label: 'Τρ. Μεταφ.', color: '#7c3aed', bg: '#ede9fe' },
|
||||||
|
{ key: 'treat', label: 'Κεράστηκε', color: '#b45309', bg: '#fef3c7' },
|
||||||
|
{ key: 'other', label: 'Άλλο', color: '#6b7280', bg: '#f3f4f6' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabBtn({ label, active, onClick, badge, warn }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '9px 18px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 700 : 500,
|
||||||
|
color: active ? '#111315' : '#6b7280',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: active ? '2px solid #111315' : '2px solid transparent',
|
||||||
|
transition: 'all 150ms',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{badge != null && (
|
||||||
|
<span style={{
|
||||||
|
minWidth: 18, height: 18, borderRadius: 99,
|
||||||
|
background: warn ? '#fee2e2' : '#f3f4f6',
|
||||||
|
color: warn ? '#dc2626' : '#374151',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '0 5px',
|
||||||
|
}}>{badge}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({ label, value, accent = '#374151' }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#f9fafb', border: '1px solid #f0f0ef',
|
||||||
|
borderRadius: 10, padding: '12px 16px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 2,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 800, color: accent, letterSpacing: '-0.02em' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 700, color: '#9ca3af',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentBar({ w, total }) {
|
||||||
|
if (!total) return null
|
||||||
|
const bars = PAY_METHODS.filter(m => (w[m.key] || 0) > 0)
|
||||||
|
if (bars.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<div style={{ display: 'flex', height: 7, borderRadius: 99, overflow: 'hidden', gap: 1 }}>
|
||||||
|
{bars.map(m => (
|
||||||
|
<div key={m.key} style={{ flex: w[m.key], background: m.color }}
|
||||||
|
title={`${m.label}: ${Math.round((w[m.key] / total) * 100)}%`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 5, flexWrap: 'wrap' }}>
|
||||||
|
{bars.map(m => (
|
||||||
|
<span key={m.key} style={{ fontSize: 10, color: m.color, fontWeight: 700 }}>
|
||||||
|
● {m.label} {fmt(w[m.key])}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FinancialRow({ label, value, sub, accent, warn, indent = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||||
|
padding: indent ? '4px 0 4px 16px' : '5px 0',
|
||||||
|
borderBottom: indent ? 'none' : '1px solid #f9fafb',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: indent ? 12 : 13, color: warn ? '#d97706' : indent ? '#6b7280' : '#374151', flex: 1 }}>
|
||||||
|
{warn && <span style={{ marginRight: 5 }}>⚠</span>}{label}
|
||||||
|
</span>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: indent ? 12 : 13.5, fontWeight: indent ? 500 : 700, color: accent || '#111315', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{sub && <div style={{ fontSize: 10.5, color: '#9ca3af' }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ data }) {
|
||||||
|
const waiters = data.waiters.filter(w => w.role === 'waiter')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Top stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
|
||||||
|
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
|
||||||
|
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
|
||||||
|
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />
|
||||||
|
<StatPill label="Σερβιτόροι" value={String(waiters.length)} accent="#0d7a8a" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΑΝΟΙΓΜΑ</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>{fmtDateTime(data.opened_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΚΛΕΙΣΙΜΟ</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>
|
||||||
|
{data.closed_at ? fmtDateTime(data.closed_at) : <span style={{ color: '#16a34a' }}>Ημέρα Ενεργή</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full P&L */}
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
{/* REVENUE */}
|
||||||
|
<div style={{ padding: '10px 16px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Έσοδα</SectionTitle>
|
||||||
|
<FinancialRow label="Σύνολο πωλήσεων" value={fmt(data.total_revenue)} accent="#111315" />
|
||||||
|
{data.tabbed_revenue > 0 && (
|
||||||
|
<FinancialRow label="Καρτέλες (μη εισπραχθέντα)" value={`− ${fmt(data.tabbed_revenue)}`} accent="#d97706" indent />
|
||||||
|
)}
|
||||||
|
<FinancialRow
|
||||||
|
label="Καθαρά εισπραχθέντα"
|
||||||
|
value={fmt(data.total_revenue - (data.tabbed_revenue || 0))}
|
||||||
|
accent="#16a34a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COGS */}
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Κόστος Πωληθέντων (COGS)</SectionTitle>
|
||||||
|
<FinancialRow label="Κόστος καταγεγραμμένων" value={fmt(data.cogs_trackable)} accent="#374151" />
|
||||||
|
{data.cogs_has_gap && (
|
||||||
|
<FinancialRow
|
||||||
|
label={`${data.cogs_uncosted_count} αντ. χωρίς κόστος (${fmt(data.cogs_uncosted_revenue)} έσοδα)`}
|
||||||
|
value="?" warn indent
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FinancialRow
|
||||||
|
label="Μικτό κέρδος"
|
||||||
|
value={fmt(data.gross_profit)}
|
||||||
|
accent={data.gross_profit >= 0 ? '#16a34a' : '#dc2626'}
|
||||||
|
warn={data.cogs_has_gap}
|
||||||
|
sub={data.cogs_has_gap ? 'Μερικό — υπάρχουν αντ. χωρίς κόστος' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LABOR */}
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Μισθοδοσία</SectionTitle>
|
||||||
|
<FinancialRow label="Κόστος εργασίας (καταγεγρ.)" value={fmt(data.total_labor_cost)} accent="#374151" />
|
||||||
|
{data.labor_untracked_shifts > 0 && (
|
||||||
|
<FinancialRow label={`${data.labor_untracked_shifts} βάρδιες χωρίς ωριαία αμοιβή`} value="?" warn indent />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WASTE */}
|
||||||
|
{(data.waste_item_count > 0) && (
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Αποβλήτα</SectionTitle>
|
||||||
|
<FinancialRow
|
||||||
|
label={`${data.waste_item_count} καταχωρήσεις`}
|
||||||
|
value={fmt(data.waste_estimated_cost)}
|
||||||
|
accent="#d97706"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EXPENSES */}
|
||||||
|
{data.expenses_total > 0 && (
|
||||||
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<SectionTitle>Έξοδα (σήμερα)</SectionTitle>
|
||||||
|
<FinancialRow label="Σύνολο εξόδων" value={fmt(data.expenses_total)} accent="#374151" />
|
||||||
|
<FinancialRow label="Πλη<CEBB><CEB7>ωμένα σήμερα" value={fmt(data.expenses_paid_today)} accent="#374151" indent />
|
||||||
|
{data.expenses_due > 0 && (
|
||||||
|
<FinancialRow label="Ακόμη οφείλονται" value={fmt(data.expenses_due)} accent="#dc2626" warn indent />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NET */}
|
||||||
|
<div style={{ padding: '12px 16px', background: '#f9fafb' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>
|
||||||
|
Εκτιμώμενο Καθαρό Αποτέλεσμα
|
||||||
|
</div>
|
||||||
|
{data.net_has_unknowns && (
|
||||||
|
<div style={{ fontSize: 11, color: '#d97706', marginTop: 1 }}>
|
||||||
|
⚠ Μερικό — υπάρχουν άγνωστα κόστη
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 20, fontWeight: 800,
|
||||||
|
color: data.net_estimate >= 0 ? '#16a34a' : '#dc2626',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{fmt(data.net_estimate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment breakdown */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Κατανομή Πληρωμών</SectionTitle>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px' }}>
|
||||||
|
{(() => {
|
||||||
|
const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 }
|
||||||
|
for (const w of data.waiters) {
|
||||||
|
for (const k of Object.keys(totals)) totals[k] += (w[k] || 0)
|
||||||
|
}
|
||||||
|
return <PaymentBar w={totals} total={data.total_revenue} />
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cash reconciliation summary (if counted) */}
|
||||||
|
{data.store_closing_cash != null && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Ταμείο</SectionTitle>
|
||||||
|
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<FinancialRow label="Αναμενόμενο ταμείο" value={fmt(data.store_expected_closing)} />
|
||||||
|
<FinancialRow label="Μετρημένο ταμείο" value={fmt(data.store_closing_cash)} />
|
||||||
|
<FinancialRow
|
||||||
|
label="Διαφορά"
|
||||||
|
value={data.store_cash_discrepancy === 0 ? '✓ Εντάξει' : fmt(data.store_cash_discrepancy)}
|
||||||
|
accent={data.store_cash_discrepancy === 0 ? '#16a34a' : '#dc2626'}
|
||||||
|
warn={data.store_cash_discrepancy !== 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaitersTab({ data }) {
|
||||||
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
|
||||||
|
// Fixed column widths for the collapsed summary row
|
||||||
|
const colPx = { orders: 88, tables: 88, items: 80, collected: 104 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.waiters.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν σερβιτόροι</div>
|
||||||
|
)}
|
||||||
|
{data.waiters.map(w => {
|
||||||
|
const badge = roleBadge(w.role)
|
||||||
|
const isOpen = expanded === w.waiter_id
|
||||||
|
|
||||||
|
// Compute current cash in drawer: starting_cash + collected so far
|
||||||
|
const drawerNow = (w.starting_cash ?? 0) + (w.total_collected ?? 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={w.waiter_id} style={{
|
||||||
|
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(isOpen ? null : w.waiter_id)}
|
||||||
|
style={{
|
||||||
|
width: '100%', background: 'white', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: badge.bg, color: badge.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 15, fontWeight: 800,
|
||||||
|
}}>
|
||||||
|
{w.waiter_name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + role */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{w.waiter_name}</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 99,
|
||||||
|
background: badge.bg, color: badge.color,
|
||||||
|
}}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4 fixed-width metric columns */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
|
||||||
|
{ label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ', value: w.tables_served ?? 0, width: colPx.tables, color: '#111315' },
|
||||||
|
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, width: colPx.items, color: '#111315' },
|
||||||
|
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
|
||||||
|
].map(col => (
|
||||||
|
<div key={col.label} style={{
|
||||||
|
width: col.width, flexShrink: 0, textAlign: 'right', padding: '0 6px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap' }}>{col.label}</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: col.color, whiteSpace: 'nowrap' }}>{col.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2.5"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ flexShrink: 0, transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 200ms' }}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{/* Row 1: shift timing + payroll */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'ΕΝΑΡΞΗ ΒΑΡΔΙΑΣ', value: fmtTime(w.shift_started_at), color: '#111315' },
|
||||||
|
{
|
||||||
|
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
|
||||||
|
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
|
||||||
|
color: w.shift_ended_at ? '#111315' : '#16a34a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ΜΙΣΘΟΣ / ΩΡΑ',
|
||||||
|
value: w.hourly_rate_snapshot != null ? fmt(w.hourly_rate_snapshot) : '—',
|
||||||
|
color: '#111315',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ΜΙΣΘΟΣ (εως τώρα)',
|
||||||
|
value: w.shift_pay != null ? fmt(w.shift_pay) : 'Δεν παρακολ.',
|
||||||
|
color: w.shift_pay != null ? '#059669' : '#9ca3af',
|
||||||
|
},
|
||||||
|
].map(cell => (
|
||||||
|
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Row 2: cash + collections */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
|
||||||
|
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
|
||||||
|
color: '#111315',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ΤΑΜΕΙΟ ΤΩΡΑ',
|
||||||
|
value: fmt(drawerNow),
|
||||||
|
color: '#3758c9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.',
|
||||||
|
value: w.items_paid,
|
||||||
|
color: '#111315',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ',
|
||||||
|
value: w.tables_served ?? 0,
|
||||||
|
color: '#111315',
|
||||||
|
},
|
||||||
|
].map(cell => (
|
||||||
|
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PaymentBar w={w} total={w.total_collected} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZonesTab({ data }) {
|
||||||
|
const maxRev = Math.max(...data.zones.map(z => z.revenue), 1)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.zones.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν ζώνες</div>
|
||||||
|
)}
|
||||||
|
{data.zones.map(z => (
|
||||||
|
<div key={z.group_id} style={{
|
||||||
|
border: '1px solid #e5e7eb', borderRadius: 12,
|
||||||
|
padding: '16px 18px', background: 'white',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 12, height: 12, borderRadius: 3, flexShrink: 0,
|
||||||
|
background: z.color || '#9ca3af',
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: '#111315', flex: 1 }}>{z.group_name}</span>
|
||||||
|
<span style={{ fontSize: 20, fontWeight: 800, color: '#111315' }}>{fmt(z.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 99,
|
||||||
|
background: z.color || '#9ca3af',
|
||||||
|
width: `${Math.round((z.revenue / maxRev) * 100)}%`,
|
||||||
|
transition: 'width 400ms ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΠΑΡΑΓΓΕΛΙΕΣ </span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.orders}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΑΝΤΙΚΕΙΜΕΝΑ </span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.items}</span>
|
||||||
|
</div>
|
||||||
|
{z.orders > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>Μ.Ο./ΠΑΡΑΓ. </span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700 }}>{fmt(z.revenue / z.orders)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrintersTab({ data }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.printers.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν δεδομένα εκτύπωσης</div>
|
||||||
|
)}
|
||||||
|
{data.printers.map(p => {
|
||||||
|
const successRate = p.jobs > 0 ? Math.round((p.success / p.jobs) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={p.printer_id} style={{
|
||||||
|
border: '1px solid #e5e7eb', borderRadius: 12,
|
||||||
|
padding: '16px 18px', background: 'white',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
|
||||||
|
background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{p.printer_name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af' }}>{p.ip_address}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{fmt(p.items_value)}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>αξία εκτυπ.</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 700, padding: '3px 10px', borderRadius: 99, marginLeft: 4,
|
||||||
|
background: successRate === 100 ? '#dcfce7' : successRate >= 80 ? '#fef9c3' : '#fee2e2',
|
||||||
|
color: successRate === 100 ? '#16a34a' : successRate >= 80 ? '#a16207' : '#dc2626',
|
||||||
|
}}>{successRate}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', display: 'flex' }}>
|
||||||
|
<div style={{ flex: p.success, background: '#16a34a' }} />
|
||||||
|
<div style={{ flex: p.failed, background: '#dc2626' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20 }}>
|
||||||
|
<div><span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΕΡΓΑΣΙΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.jobs}</span></div>
|
||||||
|
<div><span style={{ fontSize: 11, color: '#16a34a', fontWeight: 600 }}>ΕΠΙΤΥΧΕΙΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.success}</span></div>
|
||||||
|
{p.failed > 0 && <div><span style={{ fontSize: 11, color: '#dc2626', fontWeight: 600 }}>ΑΠΟΤΥΧΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.failed}</span></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CashReconciliationTab({ data, storeCash, onStoreCashChange }) {
|
||||||
|
const expected = data.store_expected_closing ?? 0
|
||||||
|
const counted = storeCash !== '' ? parseFloat(storeCash) : null
|
||||||
|
const discrepancy = counted != null ? counted - expected : null
|
||||||
|
const isShort = discrepancy != null && discrepancy < -0.005
|
||||||
|
const isOver = discrepancy != null && discrepancy > 0.005
|
||||||
|
const isClean = discrepancy != null && !isShort && !isOver
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, padding: '16px 18px', background: '#f9fafb' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Σύνοψη Εισπράξεων Βάρδιας</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13.5 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>Σύνολο εισπράξεων σερβιτόρων</span>
|
||||||
|
<span style={{ fontWeight: 700, color: '#111315' }}>{fmt(data.total_waiter_collected)}</span>
|
||||||
|
</div>
|
||||||
|
{data.store_opening_cash != null && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6b7280' }}>Αρχικό ταμείο</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#111315' }}>{fmt(data.store_opening_cash)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #e5e7eb', paddingTop: 6, marginTop: 2 }}>
|
||||||
|
<span style={{ color: '#374151', fontWeight: 600 }}>Αναμενόμενο ταμείο τώρα</span>
|
||||||
|
<span style={{ fontWeight: 800, color: '#111315', fontSize: 15 }}>{fmt(expected)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||||||
|
Μετρήστε το ταμείο — πόσα μετρητά υπάρχουν;
|
||||||
|
<span style={{ fontWeight: 400, color: '#9ca3af' }}> (προαιρετικό)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={storeCash}
|
||||||
|
onChange={e => onStoreCashChange(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '10px 14px', border: '1.5px solid #e5e7eb',
|
||||||
|
borderRadius: 10, fontSize: 18, fontWeight: 700, color: '#111315',
|
||||||
|
outline: 'none', boxSizing: 'border-box', background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{discrepancy != null && (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px', borderRadius: 10,
|
||||||
|
background: isClean ? '#f0fdf4' : '#fef2f2',
|
||||||
|
border: `1.5px solid ${isClean ? '#86efac' : '#fca5a5'}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: isClean ? '#16a34a' : '#dc2626', marginBottom: 4 }}>
|
||||||
|
{isClean && '✓ Ταμείο εντάξει'}
|
||||||
|
{isShort && `⚠ Έλλειμμα ${fmt(Math.abs(discrepancy))}`}
|
||||||
|
{isOver && `⚠ Πλεόνασμα ${fmt(Math.abs(discrepancy))}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: isClean ? '#166534' : '#7f1d1d', lineHeight: 1.5 }}>
|
||||||
|
{isClean && 'Το φυσικό ταμείο συμφωνεί με τις καταγεγραμμένες εισπράξεις.'}
|
||||||
|
{isShort && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} λιγότερα από το αναμενόμενο. Το κενό δεν αποδίδεται σε κάποιον σερβιτόρο συγκεκριμένα.`}
|
||||||
|
{isOver && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} περισσότερα από το αναμενόμενο.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{storeCash === '' && (
|
||||||
|
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>
|
||||||
|
Αν παραλείψετε τη μέτρηση, η επαλήθευση δεν θα καταγραφεί.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecksTab({ data, onForceClose, closing, isOpen }) {
|
||||||
|
const { checks } = data
|
||||||
|
const allGood = checks.open_orders === 0
|
||||||
|
const hasUnpaid = checks.with_unpaid_items > 0
|
||||||
|
const hasActiveShifts = checks.active_shifts > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
border: `1px solid ${checks.open_orders > 0 ? '#fecaca' : '#bbf7d0'}`,
|
||||||
|
borderRadius: 12, padding: '16px 18px', background: checks.open_orders > 0 ? '#fff5f5' : '#f0fdf4',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
|
||||||
|
background: checks.open_orders > 0 ? '#fee2e2' : '#dcfce7',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
|
||||||
|
}}>
|
||||||
|
{checks.open_orders > 0 ? '⚠️' : '✓'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ανοιχτά Τραπέζια</div>
|
||||||
|
{checks.open_orders > 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: '#dc2626', marginTop: 2 }}>
|
||||||
|
{checks.open_orders} τραπέζια ακόμα ανοιχτά
|
||||||
|
{hasUnpaid && ` · ${checks.with_unpaid_items} με απλήρωτα αντικείμενα`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Όλα τα τραπέζια έχουν κλείσει</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
border: `1px solid ${hasActiveShifts ? '#fde68a' : '#bbf7d0'}`,
|
||||||
|
borderRadius: 12, padding: '16px 18px', background: hasActiveShifts ? '#fffbeb' : '#f0fdf4',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
|
||||||
|
background: hasActiveShifts ? '#fef3c7' : '#dcfce7',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
|
||||||
|
}}>
|
||||||
|
{hasActiveShifts ? '⏳' : '✓'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ενεργές Βάρδιες</div>
|
||||||
|
{hasActiveShifts ? (
|
||||||
|
<div style={{ fontSize: 13, color: '#92400e', marginTop: 2 }}>
|
||||||
|
{checks.active_shifts} βάρδι{checks.active_shifts === 1 ? 'α' : 'ες'} ακόμα ενεργ{checks.active_shifts === 1 ? 'ή' : 'ές'} — θα κλείσουν αυτόματα
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Δεν υπάρχουν ενεργές βάρδιες</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allGood ? (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #bbf7d0', borderRadius: 12, padding: '16px 18px',
|
||||||
|
background: '#f0fdf4', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20 }}>✅</span>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#166534' }}>
|
||||||
|
Ο έλεγχος πέρασε — η ημέρα είναι έτοιμη να κλείσει.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hasUnpaid ? (
|
||||||
|
<div style={{ border: '1px solid #fecaca', borderRadius: 12, padding: '16px 18px', background: '#fff5f5' }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
|
||||||
|
<strong>Προσοχή:</strong> Υπάρχουν απλήρωτα αντικείμενα. Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά δεν θα καταγραφούν στις αναφορές.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: '1px solid #fde68a', borderRadius: 12, padding: '16px 18px', background: '#fffbeb' }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#78350f', lineHeight: 1.5 }}>
|
||||||
|
Υπάρχουν ανοιχτά τραπέζια χωρίς απλήρωτα αντικείμενα. Θα κλείσουν αυτόματα.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<button
|
||||||
|
onClick={onForceClose}
|
||||||
|
disabled={closing}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, height: 46, borderRadius: 10, border: 'none',
|
||||||
|
cursor: closing ? 'default' : 'pointer',
|
||||||
|
background: hasUnpaid ? '#dc2626' : '#111315',
|
||||||
|
color: 'white', fontSize: 14, fontWeight: 700,
|
||||||
|
opacity: closing ? 0.7 : 1, transition: 'opacity 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closing ? 'Κλείσιμο…' : hasUnpaid ? '⚠ Αναγκαστικό Κλείσιμο' : 'Κλείσε Ημέρα'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = false, onDayClosed }) {
|
||||||
|
const [tab, setTab] = useState('overview')
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [storeCash, setStoreCash] = useState('') // Phase 2E: store cash count
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['workday-summary', dayId ?? 'current'],
|
||||||
|
queryFn: () => client.get('/api/business-day/summary' + (dayId ? `?day_id=${dayId}` : '')).then(r => r.data),
|
||||||
|
refetchInterval: showCloseAction ? 15_000 : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleClose(force = false) {
|
||||||
|
setClosing(true)
|
||||||
|
try {
|
||||||
|
const payload = { force }
|
||||||
|
if (storeCash !== '') payload.store_closing_cash = parseFloat(storeCash)
|
||||||
|
await client.post('/api/business-day/close', payload)
|
||||||
|
onDayClosed?.()
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e.response?.data?.detail
|
||||||
|
if (e.response?.status === 409 && detail?.open_orders) {
|
||||||
|
setTab('checks')
|
||||||
|
} else {
|
||||||
|
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setClosing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'Επισκόπηση' },
|
||||||
|
{ id: 'waiters', label: 'Προσωπικό', badge: data?.waiters?.length },
|
||||||
|
{ id: 'zones', label: 'Ζώνες', badge: data?.zones?.length },
|
||||||
|
{ id: 'printers', label: 'Εκτυπωτές', badge: data?.printers?.length },
|
||||||
|
...(showCloseAction ? [
|
||||||
|
{ id: 'cash', label: 'Ταμείο' },
|
||||||
|
{
|
||||||
|
id: 'checks', label: 'Έλεγχοι',
|
||||||
|
badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null,
|
||||||
|
warn: data?.checks?.with_unpaid_items > 0,
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const isOpen = data?.status === 'open'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 1000, padding: 16,
|
||||||
|
}}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'white', borderRadius: 16, width: '100%', maxWidth: 760,
|
||||||
|
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 24px 0', borderBottom: '1px solid #f0f0ef',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 9, background: '#111315',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
|
<line x1="9" y1="21" x2="9" y2="9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 800, color: '#111315' }}>
|
||||||
|
{showCloseAction ? 'Κλείσιμο Εργάσιμης Ημέρας' : 'Στατιστικά Ημέρας'}
|
||||||
|
</div>
|
||||||
|
{data && (
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
{data.status === 'open'
|
||||||
|
? <span style={{ color: '#16a34a', fontWeight: 600 }}>● Ημέρα Ενεργή</span>
|
||||||
|
: <span>Κλειστή</span>
|
||||||
|
}
|
||||||
|
{' · '}Άνοιγμα {fmtDateTime(data?.opened_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8, border: '1px solid #e5e7eb',
|
||||||
|
background: 'white', cursor: 'pointer', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2.5" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 0, overflowX: 'auto' }}>
|
||||||
|
{tabs.map(t => (
|
||||||
|
<TabBtn key={t.id} label={t.label} active={tab === t.id}
|
||||||
|
onClick={() => setTab(t.id)} badge={t.badge} warn={t.warn} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '22px 24px' }}>
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '48px 0' }}>Φόρτωση…</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#dc2626', fontSize: 13, padding: '48px 0' }}>Σφάλμα φόρτωσης δεδομένων</div>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{tab === 'overview' && <OverviewTab data={data} />}
|
||||||
|
{tab === 'waiters' && <WaitersTab data={data} />}
|
||||||
|
{tab === 'zones' && <ZonesTab data={data} />}
|
||||||
|
{tab === 'printers' && <PrintersTab data={data} />}
|
||||||
|
{tab === 'cash' && (
|
||||||
|
<CashReconciliationTab
|
||||||
|
data={data}
|
||||||
|
storeCash={storeCash}
|
||||||
|
onStoreCashChange={setStoreCash}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'checks' && (
|
||||||
|
<ChecksTab
|
||||||
|
data={data}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onForceClose={() => handleClose(data.checks.open_orders > 0)}
|
||||||
|
closing={closing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — only shown when in close-flow and not already on checks tab */}
|
||||||
|
{showCloseAction && data && tab !== 'checks' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 24px', borderTop: '1px solid #f0f0ef',
|
||||||
|
display: 'flex', gap: 10, justifyContent: 'flex-end', background: 'white',
|
||||||
|
}}>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
height: 40, padding: '0 18px', borderRadius: 9,
|
||||||
|
border: '1px solid #e5e7eb', background: 'white',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||||
|
}}>Ακύρωση</button>
|
||||||
|
{tab !== 'cash' ? (
|
||||||
|
<button onClick={() => setTab('cash')} style={{
|
||||||
|
height: 40, padding: '0 20px', borderRadius: 9,
|
||||||
|
background: '#111315', border: 'none', color: 'white',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}>Μέτρηση Ταμείου →</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setTab('checks')} style={{
|
||||||
|
height: 40, padding: '0 20px', borderRadius: 9,
|
||||||
|
background: '#111315', border: 'none', color: 'white',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}>Προχωρήστε στους Ελέγχους →</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
manager_dashboard/src/hooks/usePhase2Features.js
Normal file
61
manager_dashboard/src/hooks/usePhase2Features.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'phase2_features'
|
||||||
|
|
||||||
|
// All Phase 2 features with their sidebar metadata.
|
||||||
|
// enabled: true = shown by default (feature is polished)
|
||||||
|
// enabled: false = hidden by default (still in development)
|
||||||
|
export const PHASE2_FEATURE_DEFS = [
|
||||||
|
{ id: 'online-orders', label: 'Online Orders', route: '/online-orders', enabled: true },
|
||||||
|
{ id: 'notes', label: 'Σημειώσεις & Εργασίες', route: '/notes', enabled: true },
|
||||||
|
{ id: 'expenses', label: 'Έξοδα', route: '/expenses', enabled: true },
|
||||||
|
{ id: 'contacts', label: 'Επαφές / Προμηθευτές', route: '/contacts', enabled: true },
|
||||||
|
{ id: 'customers', label: 'Πελάτες', route: '/customers', enabled: true },
|
||||||
|
{ id: 'tabs', label: 'Καρτέλες', route: '/tabs', enabled: true },
|
||||||
|
{ id: 'waste', label: 'Αποβλήτα / Φθορές', route: '/waste', enabled: true },
|
||||||
|
{ id: 'kds', label: 'KDS — Κουζίνα', route: '/kds', enabled: true },
|
||||||
|
{ id: 'schedule', label: 'Πρόγραμμα Βαρδιών', route: '/schedule', enabled: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function _load() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeatureEnabled(id) {
|
||||||
|
const overrides = _load()
|
||||||
|
if (id in overrides) return overrides[id]
|
||||||
|
const def = PHASE2_FEATURE_DEFS.find(f => f.id === id)
|
||||||
|
return def ? def.enabled : true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFeatureEnabled(id, value) {
|
||||||
|
const overrides = _load()
|
||||||
|
overrides[id] = value
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
|
||||||
|
// Notify other components in this tab
|
||||||
|
window.dispatchEvent(new Event('phase2features'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePhase2Features() {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => tick(n => n + 1)
|
||||||
|
window.addEventListener('phase2features', handler)
|
||||||
|
window.addEventListener('storage', handler)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('phase2features', handler)
|
||||||
|
window.removeEventListener('storage', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return PHASE2_FEATURE_DEFS.map(f => ({
|
||||||
|
...f,
|
||||||
|
enabled: isFeatureEnabled(f.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
218
manager_dashboard/src/pages/ContactsPage.jsx
Normal file
218
manager_dashboard/src/pages/ContactsPage.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
const TYPE_LABELS = {
|
||||||
|
supplier: 'Προμηθευτής',
|
||||||
|
staff: 'Προσωπικό',
|
||||||
|
utility: 'Κοινή Χρεία',
|
||||||
|
other: 'Άλλο',
|
||||||
|
}
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
supplier: { bg: '#eff6ff', color: '#1d4ed8' },
|
||||||
|
staff: { bg: '#f0fdf4', color: '#15803d' },
|
||||||
|
utility: { bg: '#fefce8', color: '#a16207' },
|
||||||
|
other: { bg: '#f3f4f6', color: '#374151' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM = { name: '', type: 'supplier', phone: '', email: '', address: '', notes: '' }
|
||||||
|
|
||||||
|
function ContactModal({ initial, onClose, onSave, isPending }) {
|
||||||
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
|
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
|
||||||
|
const isNew = !initial?.id
|
||||||
|
const canSave = form.name.trim()
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
|
||||||
|
background: 'white',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 50, padding: 20,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέα Επαφή' : 'Επεξεργασία Επαφής'}</h2>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
|
||||||
|
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Νίκος Γαλακτοπωλείο" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τύπος</label>
|
||||||
|
<select style={inputStyle} value={form.type} onChange={e => f('type', e.target.value)}>
|
||||||
|
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
|
||||||
|
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
|
||||||
|
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="info@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Διεύθυνση</label>
|
||||||
|
<input style={inputStyle} value={form.address} onChange={e => f('address', e.target.value)} placeholder="Οδός, Πόλη" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Εσωτερικές σημειώσεις…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(form)}
|
||||||
|
disabled={!canSave || isPending}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||||
|
cursor: canSave && !isPending ? 'pointer' : 'default',
|
||||||
|
background: canSave ? '#111827' : '#e5e7eb',
|
||||||
|
color: canSave ? 'white' : '#9ca3af',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [modal, setModal] = useState(null) // null | { contact } | { contact: null } for new
|
||||||
|
|
||||||
|
const { data: contacts = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
queryFn: () => client.get('/api/contacts/').then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ form, id }) => id
|
||||||
|
? client.put(`/api/contacts/${id}`, form)
|
||||||
|
: client.post('/api/contacts/', form),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Αποθηκεύτηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deactivate = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/contacts/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||||||
|
toast.success('Η επαφή αποενεργοποιήθηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ contact: null })}
|
||||||
|
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
+ Νέα Επαφή
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
|
||||||
|
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && contacts.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
|
||||||
|
Δεν υπάρχουν επαφές ακόμα. Προσθέστε τον πρώτο προμηθευτή.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contacts.length > 0 && (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
{['Όνομα', 'Τύπος', 'Τηλέφωνο', 'Email', ''].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contacts.map((c, i) => {
|
||||||
|
const tc = TYPE_COLORS[c.type] || TYPE_COLORS.other
|
||||||
|
return (
|
||||||
|
<tr key={c.id} style={{ borderBottom: i < contacts.length - 1 ? '1px solid #f3f4f6' : 'none', background: 'white' }}>
|
||||||
|
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#111315' }}>
|
||||||
|
{c.name}
|
||||||
|
{c.notes && <div style={{ fontSize: 11.5, color: '#9ca3af', fontWeight: 400, marginTop: 1 }}>{c.notes.slice(0, 60)}{c.notes.length > 60 ? '…' : ''}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: tc.bg, color: tc.color }}>
|
||||||
|
{TYPE_LABELS[c.type] || c.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', color: c.phone ? '#374151' : '#d1d5db' }}>{c.phone || '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', color: c.email ? '#374151' : '#d1d5db' }}>{c.email || '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ contact: c })}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, fontWeight: 500, cursor: 'pointer', color: '#374151' }}
|
||||||
|
>
|
||||||
|
Επεξεργασία
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm(`Αποενεργοποίηση "${c.name}";`)) deactivate.mutate(c.id) }}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
|
||||||
|
>
|
||||||
|
Απενεργ.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ContactModal
|
||||||
|
initial={modal.contact ? { ...modal.contact, phone: modal.contact.phone || '', email: modal.contact.email || '', address: modal.contact.address || '', notes: modal.contact.notes || '' } : null}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={save.isPending}
|
||||||
|
onSave={(form) => save.mutate({
|
||||||
|
form: { name: form.name, type: form.type, phone: form.phone || null, email: form.email || null, address: form.address || null, notes: form.notes || null },
|
||||||
|
id: modal.contact?.id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
350
manager_dashboard/src/pages/CustomersPage.jsx
Normal file
350
manager_dashboard/src/pages/CustomersPage.jsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_STATUS_LABELS = {
|
||||||
|
closed: 'Κλειστή', paid: 'Πληρωμένη', open: 'Ανοιχτή',
|
||||||
|
partially_paid: 'Μερική πλ.', cancelled: 'Ακυρωμένη',
|
||||||
|
}
|
||||||
|
const ORDER_STATUS_COLORS = {
|
||||||
|
closed: '#6b7280', paid: '#16a34a', open: '#3b82f6',
|
||||||
|
partially_paid: '#d97706', cancelled: '#ef4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer modal (create / edit) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPTY_FORM = { name: '', nickname: '', phone: '', email: '', notes: '' }
|
||||||
|
|
||||||
|
function CustomerModal({ initial, onClose, onSave, isPending }) {
|
||||||
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
|
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
|
||||||
|
const isNew = !initial?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέος Πελάτης' : 'Επεξεργασία Πελάτη'}</h2>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
|
||||||
|
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Γιώργος Παπαδόπουλος" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Παρατσούκλι</label>
|
||||||
|
<input style={inputStyle} value={form.nickname} onChange={e => f('nickname', e.target.value)} placeholder="π.χ. Μεγάλος Γιάννης" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
|
||||||
|
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
|
||||||
|
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="email@example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(αλλεργίες, προτιμήσεις…)</span></label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="π.χ. Αλλεργία στους ξηρούς καρπούς, VIP πελάτης…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(form)}
|
||||||
|
disabled={!form.name.trim() || isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: form.name.trim() ? 'pointer' : 'default', background: form.name.trim() ? '#111827' : '#e5e7eb', color: form.name.trim() ? 'white' : '#9ca3af' }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer detail panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CustomerDetail({ customer, onEdit, onClose }) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['customer-orders', customer.id],
|
||||||
|
queryFn: () => client.get(`/api/customers/${customer.id}/orders`).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: tabsData = [] } = useQuery({
|
||||||
|
queryKey: ['customer-tabs', customer.id],
|
||||||
|
queryFn: () => client.get(`/api/tabs/customer/${customer.id}`).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openTab = tabsData.find(t => t.status === 'open')
|
||||||
|
const orders = data?.orders || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'white', borderLeft: '1px solid #f0f0ef' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{customer.name}</div>
|
||||||
|
{customer.nickname && <div style={{ fontSize: 13, color: '#9ca3af', marginTop: 2 }}>«{customer.nickname}»</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={onEdit} style={{ padding: '5px 12px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 12, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>Επεξεργασία</button>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af', padding: '0 4px' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 8, padding: '14px 22px', flexShrink: 0, borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Επισκέψεις', value: customer.visit_count },
|
||||||
|
{ label: 'Σύνολο εξόδων', value: fmt(customer.total_spent) },
|
||||||
|
{ label: 'Μέσος λογ.', value: customer.visit_count > 0 ? fmt(customer.total_spent / customer.visit_count) : '—' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: '#111315', marginTop: 3 }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open tab banner */}
|
||||||
|
{openTab && (
|
||||||
|
<div style={{ padding: '10px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#dc2626' }}>Ανοιχτή Καρτέλα</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
{openTab.entries.length} χρεώσεις · πληρωμένο {fmt(openTab.total_paid)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: '#dc2626' }}>{fmt(openTab.balance)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact info */}
|
||||||
|
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
|
||||||
|
{(customer.phone || customer.email || customer.notes) ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
|
||||||
|
{customer.phone && <div><span style={{ color: '#9ca3af', marginRight: 6 }}>📞</span>{customer.phone}</div>}
|
||||||
|
{customer.email && <div><span style={{ color: '#9ca3af', marginRight: 6 }}>✉</span>{customer.email}</div>}
|
||||||
|
{customer.notes && (
|
||||||
|
<div style={{ marginTop: 4, padding: '8px 12px', background: '#fffbeb', borderRadius: 8, border: '1px solid #fef3c7', fontSize: 12.5, color: '#92400e', lineHeight: 1.5 }}>
|
||||||
|
📝 {customer.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν υπάρχουν στοιχεία επικοινωνίας.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visit history */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Ιστορικό Επισκέψεων</div>
|
||||||
|
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && orders.length === 0 && (
|
||||||
|
<div style={{ color: '#d1d5db', fontSize: 13 }}>Δεν υπάρχουν επισκέψεις ακόμα.</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{orders.map(o => (
|
||||||
|
<div key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
|
||||||
|
{o.table_name ? `Τραπέζι ${o.table_name}` : 'Χωρίς τραπέζι'}
|
||||||
|
{' '}
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 6px', borderRadius: 99, background: '#f3f4f6', color: ORDER_STATUS_COLORS[o.status] || '#6b7280' }}>
|
||||||
|
{ORDER_STATUS_LABELS[o.status] || o.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
{fmtDateTime(o.opened_at)} · {o.item_count} αντικ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap' }}>{fmt(o.total)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selected, setSelected] = useState(null) // customer being viewed
|
||||||
|
const [modal, setModal] = useState(null) // null | { customer } | { customer: null }
|
||||||
|
|
||||||
|
const { data: customers = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['customers', search],
|
||||||
|
queryFn: () => client.get('/api/customers/', { params: search ? { search } : {} }).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ form, id }) => id
|
||||||
|
? client.put(`/api/customers/${id}`, form)
|
||||||
|
: client.post('/api/customers/', form),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['customers'] })
|
||||||
|
if (selected && res.data?.id === selected.id) {
|
||||||
|
setSelected(res.data)
|
||||||
|
}
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Αποθηκεύτηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deactivate = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/customers/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['customers'] })
|
||||||
|
if (selected?.id) setSelected(null)
|
||||||
|
toast.success('Αποενεργοποιήθηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeCount = customers.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Left: list */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', width: selected ? 360 : '100%', flexShrink: 0, borderRight: selected ? '1px solid #f0f0ef' : 'none', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 24px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πελάτες</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ customer: null })}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
+ Νέος
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Αναζήτηση με όνομα, παρατσούκλι ή τηλέφωνο…"
|
||||||
|
style={{ ...inputStyle, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && customers.length === 0 && (
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '32px 0' }}>
|
||||||
|
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν πελάτες ακόμα.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customers.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setSelected(selected?.id === c.id ? null : c)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px',
|
||||||
|
border: `1px solid ${selected?.id === c.id ? '#3b82f6' : '#f0f0ef'}`,
|
||||||
|
borderRadius: 10, background: selected?.id === c.id ? '#eff6ff' : 'white',
|
||||||
|
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: `hsl(${[...c.name].reduce((a, ch) => a + ch.charCodeAt(0), 0) % 360},55%,62%)`,
|
||||||
|
color: 'white', fontSize: 15, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{c.name.trim()[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{c.name}
|
||||||
|
{c.nickname && <span style={{ fontSize: 11, fontWeight: 500, color: '#9ca3af' }}>«{c.nickname}»</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
{c.phone || c.email || 'Χωρίς στοιχεία'}
|
||||||
|
{c.visit_count > 0 && <span style={{ marginLeft: 8, color: '#6b7280' }}>· {c.visit_count} επισκέψεις</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{c.total_spent > 0 ? fmt(c.total_spent) : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: detail panel */}
|
||||||
|
{selected && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<CustomerDetail
|
||||||
|
customer={selected}
|
||||||
|
onEdit={() => setModal({ customer: selected })}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<CustomerModal
|
||||||
|
initial={modal.customer ? {
|
||||||
|
...modal.customer,
|
||||||
|
nickname: modal.customer.nickname || '',
|
||||||
|
phone: modal.customer.phone || '',
|
||||||
|
email: modal.customer.email || '',
|
||||||
|
notes: modal.customer.notes || '',
|
||||||
|
} : null}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={save.isPending}
|
||||||
|
onSave={(form) => save.mutate({
|
||||||
|
form: {
|
||||||
|
name: form.name,
|
||||||
|
nickname: form.nickname || null,
|
||||||
|
phone: form.phone || null,
|
||||||
|
email: form.email || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
},
|
||||||
|
id: modal.customer?.id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import Badge from '../ui/Badge'
|
|||||||
import { ConfirmModal } from '../ui/Modal'
|
import { ConfirmModal } from '../ui/Modal'
|
||||||
import { LicenseContext } from '../layouts/AppLayout'
|
import { LicenseContext } from '../layouts/AppLayout'
|
||||||
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
||||||
|
import WorkdaySummaryModal from '../components/WorkdaySummaryModal'
|
||||||
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -259,7 +260,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
|
background: 'white', borderRadius: 20, width: '100%', maxWidth: 800,
|
||||||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -387,17 +388,17 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
{order && (
|
{order && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px 22px', borderTop: '1px solid #edeff1',
|
padding: '12px 22px', borderTop: '1px solid #edeff1',
|
||||||
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
|
display: 'flex', gap: 8, flexWrap: 'wrap', flexShrink: 0,
|
||||||
background: '#fafafa', alignItems: 'center',
|
background: '#fafafa', alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
{isOpen && activeItems.length > 0 && (
|
{isOpen && activeItems.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 16px', borderRadius: 8,
|
minHeight: 36, padding: '6px 16px', borderRadius: 8,
|
||||||
background: '#3758c9', border: 'none', color: 'white',
|
background: '#3758c9', border: 'none', color: 'white',
|
||||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
fontSize: 13, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>Πληρωμή όλων</button>
|
>Πληρωμή όλων</button>
|
||||||
)}
|
)}
|
||||||
@@ -406,23 +407,23 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>Κλείσιμο</button>
|
>Κλείσιμο</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #fecaca', background: '#fef2f2',
|
border: '1px solid #fecaca', background: '#fef2f2',
|
||||||
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>Ακύρωση</button>
|
>Ακύρωση</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{printers.length > 0 && (
|
{printers.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexWrap: 'wrap' }}>
|
||||||
<select
|
<select
|
||||||
value={printerId}
|
value={printerId}
|
||||||
onChange={e => setPrinterId(e.target.value)}
|
onChange={e => setPrinterId(e.target.value)}
|
||||||
@@ -439,9 +440,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
|
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
|
||||||
disabled={!printerId}
|
disabled={!printerId}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
color: '#2b2f33', fontSize: 13, fontWeight: 600,
|
color: '#2b2f33', fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
|
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>🖨 Εκτύπωση</button>
|
>🖨 Εκτύπωση</button>
|
||||||
@@ -450,9 +451,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenFull}
|
onClick={onOpenFull}
|
||||||
style={{
|
style={{
|
||||||
height: 36, padding: '0 14px', borderRadius: 8,
|
minHeight: 36, padding: '6px 14px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
marginLeft: printers.length > 0 ? 0 : 'auto',
|
marginLeft: printers.length > 0 ? 0 : 'auto',
|
||||||
}}
|
}}
|
||||||
>Πλήρης εικόνα →</button>
|
>Πλήρης εικόνα →</button>
|
||||||
@@ -547,31 +548,53 @@ function TableChip({ name, status, amount, onClick }) {
|
|||||||
// ─── End shift confirmation modal ─────────────────────────────────────────────
|
// ─── End shift confirmation modal ─────────────────────────────────────────────
|
||||||
|
|
||||||
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||||||
|
const [step, setStep] = useState(1) // 1 = notes, 2 = cash handover
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
|
const [countedCash, setCountedCash] = useState('')
|
||||||
|
|
||||||
|
const expectedHandover = (shift.starting_cash || 0) + (shift.total_collected || 0)
|
||||||
|
const counted = countedCash !== '' ? parseFloat(countedCash) : null
|
||||||
|
const discrepancy = counted != null ? counted - expectedHandover : null
|
||||||
|
const isShort = discrepancy != null && discrepancy < -0.005
|
||||||
|
const isOver = discrepancy != null && discrepancy > 0.005
|
||||||
|
const isClean = discrepancy != null && !isShort && !isOver
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 18 }}>⏹</span>
|
<span style={{ fontSize: 18 }}>⏹</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας;</div>
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας</div>
|
||||||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
|
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||||
|
{[1, 2].map(n => (
|
||||||
|
<div key={n} style={{ width: 8, height: 8, borderRadius: '50%', background: step >= n ? '#3758c9' : '#e5e7eb' }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
||||||
|
{/* Shift summary always visible */}
|
||||||
|
<div style={{ background: '#fafafa', borderRadius: 12, padding: '10px 14px', fontSize: 13, color: '#374151' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
|
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
|
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
|
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: notes */}
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||||||
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||||||
@@ -582,18 +605,65 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
|||||||
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
|
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
|
||||||
rows={2}
|
rows={2}
|
||||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
|
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
|
||||||
onFocus={e => e.target.style.borderColor = '#6366f1'}
|
|
||||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
|
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
|
||||||
<button onClick={() => onConfirm(notes)} disabled={busy}
|
<button onClick={() => setStep(2)} className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700">
|
||||||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
Συνέχεια →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: cash handover */}
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
|
||||||
|
Πόσα μετρητά παραδίδει ο σερβιτόρος; <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#6b7280', marginBottom: 6 }}>
|
||||||
|
<span>Αναμενόμενη παράδοση:</span>
|
||||||
|
<span style={{ fontWeight: 700, color: '#111315' }}>{fmtEuro(expectedHandover)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={countedCash}
|
||||||
|
onChange={e => setCountedCash(e.target.value)}
|
||||||
|
placeholder={fmtEuro(expectedHandover).replace('€', '')}
|
||||||
|
autoFocus
|
||||||
|
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 14, fontWeight: 600, color: '#111315', outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
{/* Live discrepancy feedback */}
|
||||||
|
{discrepancy != null && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, padding: '8px 12px', borderRadius: 8,
|
||||||
|
background: isClean ? '#f0fdf4' : '#fef2f2',
|
||||||
|
border: `1px solid ${isClean ? '#bbf7d0' : '#fecaca'}`,
|
||||||
|
fontSize: 12.5, fontWeight: 600,
|
||||||
|
color: isClean ? '#16a34a' : '#dc2626',
|
||||||
|
}}>
|
||||||
|
{isClean && '✓ Ταμείο εντάξει — παραδίδει το σωστό ποσό'}
|
||||||
|
{isShort && `⚠ Ελλείμμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει λιγότερα`}
|
||||||
|
{isOver && `⚠ Πλεόνασμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει περισσότερα`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setStep(1)} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">← Πίσω</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onConfirm(notes, countedCash !== '' ? parseFloat(countedCash) : null)}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
|
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -792,10 +862,111 @@ function RevenueChart({ orders }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reservations stub ────────────────────────────────────────────────────────
|
// ─── Reservations ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
pending: { label: 'Αναμονή', bg: '#f0f4ff', color: '#3758c9' },
|
||||||
|
arrived: { label: 'Έφτασε', bg: '#eef7f0', color: '#1f7042' },
|
||||||
|
cancelled: { label: 'Ακυρώθηκε', bg: '#fef2f2', color: '#dc2626' },
|
||||||
|
no_show: { label: 'Δεν ήρθε', bg: '#fff7ed', color: '#c2410c' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReservationRow({ res, tables, onStatusChange, onEdit }) {
|
||||||
|
const meta = STATUS_META[res.status] || STATUS_META.pending
|
||||||
|
const time = new Date(res.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const dateStr = new Date(res.reserved_for).toLocaleDateString('el-GR', { day: 'numeric', month: 'short' })
|
||||||
|
const tableLabel = res.table_label || (res.table_id ? `#${res.table_id}` : null)
|
||||||
|
|
||||||
function ReservationsCard() {
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderBottom: '1px solid #f3f4f6',
|
||||||
|
}}>
|
||||||
|
{/* Time block */}
|
||||||
|
<div style={{
|
||||||
|
minWidth: 44, textAlign: 'center', flexShrink: 0,
|
||||||
|
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', lineHeight: 1 }}>{time}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#8a9099', marginTop: 2 }}>{dateStr}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 13, fontWeight: 700, color: '#111315',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{res.guest_name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 2, display: 'flex', gap: 6 }}>
|
||||||
|
<span>👥 {res.party_size}</span>
|
||||||
|
{tableLabel && <span>🪑 {tableLabel}</span>}
|
||||||
|
{res.phone && <span>📞 {res.phone}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px', borderRadius: 999, fontSize: 10, fontWeight: 700, flexShrink: 0,
|
||||||
|
background: meta.bg, color: meta.color,
|
||||||
|
}}>{meta.label}</span>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
|
<button onClick={() => onEdit(res)} title="Επεξεργασία" style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>✏️</button>
|
||||||
|
{res.status === 'pending' && (
|
||||||
|
<button onClick={() => onStatusChange(res.id, 'arrived')} title="Έφτασε" style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: '1px solid #bbf7d0',
|
||||||
|
background: '#eef7f0', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>✅</button>
|
||||||
|
)}
|
||||||
|
{(res.status === 'pending' || res.status === 'no_show') && (
|
||||||
|
<button onClick={() => onStatusChange(res.id, 'cancelled')} title="Ακύρωση" style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: '1px solid #fecaca',
|
||||||
|
background: '#fef2f2', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>✖</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReservationsCard({ tables, onNew }) {
|
||||||
|
const [tab, setTab] = useState('upcoming')
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: reservations = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['reservations', tab],
|
||||||
|
queryFn: () => client.get(`/api/reservations/?tab=${tab}`).then(r => r.data),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editTarget, setEditTarget] = useState(null)
|
||||||
|
|
||||||
|
const statusMut = useMutation({
|
||||||
|
mutationFn: ({ id, status }) => client.patch(`/api/reservations/${id}/status`, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['reservations'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα ενημέρωσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabStyle = (active) => ({
|
||||||
|
height: 30, padding: '0 14px', borderRadius: 8, border: 'none',
|
||||||
|
background: active ? '#f0f4ff' : 'transparent',
|
||||||
|
color: active ? '#3758c9' : '#5a6169',
|
||||||
|
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', border: '1px solid #edeff1',
|
background: 'white', border: '1px solid #edeff1',
|
||||||
borderRadius: 16, overflow: 'hidden',
|
borderRadius: 16, overflow: 'hidden',
|
||||||
@@ -803,21 +974,300 @@ function ReservationsCard() {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '16px 20px', borderBottom: '1px solid #edeff1',
|
padding: '12px 16px', borderBottom: '1px solid #edeff1',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button style={{
|
<button style={tabStyle(tab === 'upcoming')} onClick={() => setTab('upcoming')}>Επερχόμενες</button>
|
||||||
|
<button style={tabStyle(tab === 'history')} onClick={() => setTab('history')}>Ιστορικό</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={onNew} style={{
|
||||||
height: 28, padding: '0 12px', borderRadius: 8,
|
height: 28, padding: '0 12px', borderRadius: 8,
|
||||||
border: '1px solid #dfe2e6', background: 'white',
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
color: '#3758c9', fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||||
}}>+ Νέα</button>
|
}}>+ Νέα</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Φόρτωση…</div>
|
||||||
|
) : reservations.length === 0 ? (
|
||||||
|
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
|
||||||
|
<div style={{ fontSize: 26, marginBottom: 8 }}>📅</div>
|
||||||
|
{tab === 'upcoming' ? 'Καμία επερχόμενη κράτηση' : 'Κανένα ιστορικό'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: 340, overflowY: 'auto' }}>
|
||||||
|
{reservations.map(r => (
|
||||||
|
<ReservationRow
|
||||||
|
key={r.id}
|
||||||
|
res={r}
|
||||||
|
tables={tables}
|
||||||
|
onStatusChange={(id, s) => statusMut.mutate({ id, status: s })}
|
||||||
|
onEdit={setEditTarget}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editTarget && (
|
||||||
|
<ReservationModal
|
||||||
|
tables={tables}
|
||||||
|
initial={editTarget}
|
||||||
|
onClose={() => setEditTarget(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditTarget(null)
|
||||||
|
qc.invalidateQueries({ queryKey: ['reservations'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── New / Edit Reservation Modal ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toLocalISO(d) {
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimePicker({ value, onChange }) {
|
||||||
|
// value: Date object
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
const toLocal = (d) => {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const mo = pad(d.getMonth() + 1)
|
||||||
|
const dy = pad(d.getDate())
|
||||||
|
const h = pad(d.getHours())
|
||||||
|
const mi = pad(d.getMinutes())
|
||||||
|
return `${y}-${mo}-${dy}T${h}:${mi}`
|
||||||
|
}
|
||||||
|
const fromLocal = (s) => new Date(s)
|
||||||
|
|
||||||
|
const quickOffsets = [
|
||||||
|
{ label: '+30λ', mins: 30 },
|
||||||
|
{ label: '+1ω', mins: 60 },
|
||||||
|
{ label: '+2ω', mins: 120 },
|
||||||
|
{ label: '+3ω', mins: 180 },
|
||||||
|
{ label: '+5ω', mins: 300 },
|
||||||
|
{ label: '+1μ', days: 1 },
|
||||||
|
{ label: '+2μ', days: 2 },
|
||||||
|
{ label: '+7μ', days: 7 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function applyOffset(offset) {
|
||||||
|
const base = new Date()
|
||||||
|
if (offset.mins) base.setMinutes(base.getMinutes() + offset.mins)
|
||||||
|
else if (offset.days) { base.setDate(base.getDate() + offset.days); base.setSeconds(0, 0) }
|
||||||
|
onChange(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={toLocal(value)}
|
||||||
|
onChange={e => onChange(fromLocal(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 38, borderRadius: 8,
|
||||||
|
border: '1px solid #dfe2e6', padding: '0 12px',
|
||||||
|
fontSize: 14, fontFamily: 'inherit', marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{quickOffsets.map(o => (
|
||||||
|
<button key={o.label} type="button" onClick={() => applyOffset(o)} style={{
|
||||||
|
height: 28, padding: '0 10px', borderRadius: 6,
|
||||||
|
border: '1px solid #dfe2e6', background: '#f8f9fa',
|
||||||
|
fontSize: 12, fontWeight: 600, color: '#374151', cursor: 'pointer',
|
||||||
|
}}>{o.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupedTableSelect({ tables, value, onChange }) {
|
||||||
|
// Group tables by group_id
|
||||||
|
const groups = {}
|
||||||
|
for (const t of tables) {
|
||||||
|
const key = t.group?.name || 'Χωρίς ζώνη'
|
||||||
|
if (!groups[key]) groups[key] = []
|
||||||
|
groups[key].push(t)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 38, borderRadius: 8,
|
||||||
|
border: '1px solid #dfe2e6', padding: '0 12px',
|
||||||
|
fontSize: 14, fontFamily: 'inherit', background: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Χωρίς τραπέζι —</option>
|
||||||
|
{Object.entries(groups).map(([groupName, gtables]) => (
|
||||||
|
<optgroup key={groupName} label={groupName}>
|
||||||
|
{gtables.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.label || `T${t.number}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReservationModal({ tables, initial, onClose, onSaved }) {
|
||||||
|
const isEdit = !!initial?.id
|
||||||
|
const [guestName, setGuestName] = useState(initial?.guest_name ?? '')
|
||||||
|
const [partySize, setPartySize] = useState(initial?.party_size ?? 2)
|
||||||
|
const [phone, setPhone] = useState(initial?.phone ?? '')
|
||||||
|
const [email, setEmail] = useState(initial?.email ?? '')
|
||||||
|
const [note, setNote] = useState(initial?.note ?? '')
|
||||||
|
const [tableId, setTableId] = useState(initial?.table_id ?? null)
|
||||||
|
const [reservedFor, setReservedFor] = useState(
|
||||||
|
initial?.reserved_for ? new Date(initial.reserved_for) : new Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: (payload) => isEdit
|
||||||
|
? client.patch(`/api/reservations/${initial.id}`, payload)
|
||||||
|
: client.post('/api/reservations/', payload),
|
||||||
|
onSuccess: () => { toast.success(isEdit ? 'Αποθηκεύτηκε!' : 'Κράτηση δημιουργήθηκε!'); onSaved() },
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!guestName.trim()) return toast.error('Εισάγετε όνομα')
|
||||||
|
if (partySize < 1) return toast.error('Αριθμός ατόμων > 0')
|
||||||
|
saveMut.mutate({
|
||||||
|
guest_name: guestName.trim(),
|
||||||
|
party_size: partySize,
|
||||||
|
phone: phone.trim() || null,
|
||||||
|
email: email.trim() || null,
|
||||||
|
note: note.trim() || null,
|
||||||
|
table_id: tableId,
|
||||||
|
reserved_for: toLocalISO(reservedFor),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle = { fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, display: 'block' }
|
||||||
|
const inputStyle = { width: '100%', height: 38, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9000,
|
||||||
|
background: 'rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '32px 20px', textAlign: 'center',
|
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
|
||||||
color: '#b8bdc4', fontSize: 13,
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
maxHeight: '90vh', overflowY: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
|
{/* Header */}
|
||||||
Σύντομα — σύστημα κρατήσεων υπό ανάπτυξη
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '18px 24px', borderBottom: '1px solid #edeff1',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>
|
||||||
|
{isEdit ? 'Επεξεργασία Κράτησης' : 'Νέα Κράτηση'}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
width: 30, height: 30, borderRadius: 8, border: '1px solid #edeff1',
|
||||||
|
background: 'white', fontSize: 16, cursor: 'pointer', lineHeight: 1,
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
|
{/* Name + Party size on same row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Όνομα</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={guestName}
|
||||||
|
onChange={e => setGuestName(e.target.value)}
|
||||||
|
placeholder="Ονοματεπώνυμο πελάτη"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Άτομα</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={999}
|
||||||
|
style={{ ...inputStyle, width: 80 }}
|
||||||
|
value={partySize}
|
||||||
|
onChange={e => setPartySize(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/time */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Ημερομηνία & Ώρα</label>
|
||||||
|
<DateTimePicker value={reservedFor} onChange={setReservedFor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Τραπέζι (προαιρετικό)</label>
|
||||||
|
<GroupedTableSelect tables={tables} value={tableId} onChange={setTableId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone + Email */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Τηλέφωνο</label>
|
||||||
|
<input style={inputStyle} value={phone} onChange={e => setPhone(e.target.value)} placeholder="69xxxxxxxx" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Email</label>
|
||||||
|
<input style={inputStyle} type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="email@..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Σημείωση</label>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={e => setNote(e.target.value)}
|
||||||
|
placeholder="Εσωτερική σημείωση για το προσωπικό…"
|
||||||
|
style={{
|
||||||
|
width: '100%', minHeight: 70, borderRadius: 8,
|
||||||
|
border: '1px solid #dfe2e6', padding: '8px 12px',
|
||||||
|
fontSize: 14, fontFamily: 'inherit', resize: 'vertical', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||||
|
paddingTop: 8, borderTop: '1px solid #edeff1',
|
||||||
|
}}>
|
||||||
|
<button type="button" onClick={onClose} style={{
|
||||||
|
height: 38, padding: '0 18px', borderRadius: 8,
|
||||||
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||||
|
}}>Άκυρο</button>
|
||||||
|
<button type="submit" disabled={saveMut.isPending} style={{
|
||||||
|
height: 38, padding: '0 22px', borderRadius: 8,
|
||||||
|
border: 'none', background: '#3758c9',
|
||||||
|
color: 'white', fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}>{saveMut.isPending ? 'Αποθήκευση…' : isEdit ? 'Αποθήκευση' : 'Δημιουργία'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1133,8 +1583,6 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [showStartShift, setShowStartShift] = useState(false)
|
const [showStartShift, setShowStartShift] = useState(false)
|
||||||
const [closeDetails, setCloseDetails] = useState(null)
|
|
||||||
const [forceClosing, setForceClosing] = useState(false)
|
|
||||||
const [retryingId, setRetryingId] = useState(null)
|
const [retryingId, setRetryingId] = useState(null)
|
||||||
const [quickView, setQuickView] = useState(null)
|
const [quickView, setQuickView] = useState(null)
|
||||||
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
|
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
|
||||||
@@ -1145,6 +1593,9 @@ export default function DashboardPage() {
|
|||||||
// Quick message to single waiter
|
// Quick message to single waiter
|
||||||
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
||||||
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
|
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
|
||||||
|
// Workday summary modal: 'close' = close-day flow, 'view' = read-only stats
|
||||||
|
const [workdaySummaryMode, setWorkdaySummaryMode] = useState(null)
|
||||||
|
const [showNewReservation, setShowNewReservation] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const license = useContext(LicenseContext)
|
const license = useContext(LicenseContext)
|
||||||
@@ -1211,31 +1662,14 @@ export default function DashboardPage() {
|
|||||||
openDayMut.mutate()
|
openDayMut.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCloseDay(force = false) {
|
async function handleEndShiftConfirm(notes, countedCashEnd) {
|
||||||
setForceClosing(force)
|
|
||||||
try {
|
|
||||||
await client.post('/api/business-day/close', { force })
|
|
||||||
toast.success('Ημέρα έκλεισε!')
|
|
||||||
setCloseDetails(null)
|
|
||||||
qc.invalidateQueries({ queryKey: ['business-day'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['tables'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['messages-all'] })
|
|
||||||
} catch (e) {
|
|
||||||
const detail = e.response?.data?.detail
|
|
||||||
if (e.response?.status === 409 && detail?.open_orders) setCloseDetails(detail)
|
|
||||||
else toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
|
|
||||||
} finally {
|
|
||||||
setForceClosing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEndShiftConfirm(notes) {
|
|
||||||
if (!endShiftTarget) return
|
if (!endShiftTarget) return
|
||||||
setEndShiftBusy(true)
|
setEndShiftBusy(true)
|
||||||
try {
|
try {
|
||||||
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
|
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {
|
||||||
|
notes: notes || null,
|
||||||
|
counted_cash_end: countedCashEnd ?? null,
|
||||||
|
})
|
||||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||||
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
|
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
|
||||||
setEndShiftTarget(null)
|
setEndShiftTarget(null)
|
||||||
@@ -1375,11 +1809,18 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<button onClick={() => handleCloseDay(false)} style={{
|
<>
|
||||||
|
<button onClick={() => setWorkdaySummaryMode('view')} style={{
|
||||||
|
height: 38, padding: '0 14px', borderRadius: 10,
|
||||||
|
background: 'white', border: '1px solid #dfe2e6', color: '#374151',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>📊 Στατιστικά Ημέρας</button>
|
||||||
|
<button onClick={() => setWorkdaySummaryMode('close')} style={{
|
||||||
height: 38, padding: '0 18px', borderRadius: 10,
|
height: 38, padding: '0 18px', borderRadius: 10,
|
||||||
background: '#dc2626', border: 'none', color: 'white',
|
background: '#dc2626', border: 'none', color: 'white',
|
||||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
}}>Κλείσιμο Ημέρας</button>
|
}}>Κλείσιμο Ημέρας</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
|
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
|
||||||
height: 38, padding: '0 18px', borderRadius: 10,
|
height: 38, padding: '0 18px', borderRadius: 10,
|
||||||
@@ -1515,7 +1956,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* ── Right column — Reservations + Messages ───────────────────────── */}
|
{/* ── Right column — Reservations + Messages ───────────────────────── */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
<ReservationsCard />
|
<ReservationsCard tables={tables} onNew={() => setShowNewReservation(true)} />
|
||||||
<MessagesCard waiters={allWaiters} tables={tables} />
|
<MessagesCard waiters={allWaiters} tables={tables} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1555,12 +1996,31 @@ export default function DashboardPage() {
|
|||||||
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{closeDetails && (
|
{workdaySummaryMode && (
|
||||||
<CloseConfirmModal
|
<WorkdaySummaryModal
|
||||||
details={closeDetails}
|
showCloseAction={workdaySummaryMode === 'close'}
|
||||||
onClose={() => setCloseDetails(null)}
|
onClose={() => setWorkdaySummaryMode(null)}
|
||||||
onConfirm={() => handleCloseDay(true)}
|
onDayClosed={() => {
|
||||||
busy={forceClosing}
|
toast.success('Ημέρα έκλεισε!')
|
||||||
|
qc.invalidateQueries({ queryKey: ['business-day'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['messages-all'] })
|
||||||
|
setWorkdaySummaryMode(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showNewReservation && (
|
||||||
|
<ReservationModal
|
||||||
|
tables={tables}
|
||||||
|
initial={null}
|
||||||
|
onClose={() => setShowNewReservation(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowNewReservation(false)
|
||||||
|
qc.invalidateQueries({ queryKey: ['reservations'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{quickView && (
|
{quickView && (
|
||||||
|
|||||||
437
manager_dashboard/src/pages/ExpensesPage.jsx
Normal file
437
manager_dashboard/src/pages/ExpensesPage.jsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: 'supplier', label: 'Προμηθευτής' },
|
||||||
|
{ id: 'utilities', label: 'Κοινή Χρεία' },
|
||||||
|
{ id: 'rent', label: 'Ενοίκιο' },
|
||||||
|
{ id: 'maintenance',label: 'Συντήρηση' },
|
||||||
|
{ id: 'staff', label: 'Προσωπικό' },
|
||||||
|
{ id: 'equipment', label: 'Εξοπλισμός' },
|
||||||
|
{ id: 'other', label: 'Άλλο' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
due: { label: 'Οφείλεται', bg: '#fee2e2', color: '#dc2626' },
|
||||||
|
partial: { label: 'Μερική Πληρ.', bg: '#fef9c3', color: '#a16207' },
|
||||||
|
paid: { label: 'Πληρώθηκε', bg: '#dcfce7', color: '#16a34a' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expense form modal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPTY_FORM = { description: '', category: 'supplier', contact_id: '', total_amount: '', due_date: '', notes: '' }
|
||||||
|
|
||||||
|
function ExpenseModal({ initial, contacts, onClose, onSave, isPending }) {
|
||||||
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
|
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
|
||||||
|
const isNew = !initial?.id
|
||||||
|
const canSave = form.description.trim() && parseFloat(form.total_amount) > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 540, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέο Έξοδο' : 'Επεξεργασία Εξόδου'}</h2>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Περιγραφή *</label>
|
||||||
|
<input style={inputStyle} value={form.description} onChange={e => f('description', e.target.value)} autoFocus placeholder="π.χ. Τιμολόγιο Νοεμβρίου — Γαλακτοκομικά" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Κατηγορία</label>
|
||||||
|
<select style={inputStyle} value={form.category} onChange={e => f('category', e.target.value)}>
|
||||||
|
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό (€) *</label>
|
||||||
|
<input style={inputStyle} type="number" step="0.01" min="0" value={form.total_amount} onChange={e => f('total_amount', e.target.value)} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Επαφή / Προμηθευτής</label>
|
||||||
|
<select style={inputStyle} value={form.contact_id} onChange={e => f('contact_id', e.target.value)}>
|
||||||
|
<option value="">— Χωρίς επαφή —</option>
|
||||||
|
{contacts.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ημ/νία πληρωμής</label>
|
||||||
|
<input style={inputStyle} type="date" value={form.due_date ? form.due_date.slice(0, 10) : ''} onChange={e => f('due_date', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none' }} rows={2} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Προαιρετικές σημειώσεις…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(form)}
|
||||||
|
disabled={!canSave || isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canSave && !isPending ? 'pointer' : 'default', background: canSave ? '#111827' : '#e5e7eb', color: canSave ? 'white' : '#9ca3af' }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pay modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PayModal({ expense, onClose, onPay, isPending }) {
|
||||||
|
const [amount, setAmount] = useState(String(Math.round((expense.due_amount) * 100) / 100))
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const parsed = parseFloat(amount)
|
||||||
|
const canPay = parsed > 0 && parsed <= expense.due_amount + 0.001
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>Καταγραφή Πληρωμής</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#6b7280' }}>{expense.description}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#6b7280' }}>Εκκρεμεί</span>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(expense.due_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής (€)</label>
|
||||||
|
<input style={inputStyle} type="number" step="0.01" min="0.01" max={expense.due_amount} value={amount} onChange={e => setAmount(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
|
||||||
|
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μετρητά στο χέρι" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPay(parsed, notes || null)}
|
||||||
|
disabled={!canPay || isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay && !isPending ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expense row (expandable) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ExpenseRow({ expense, isExpanded, onToggle, onPay, onEdit, onDelete }) {
|
||||||
|
const sc = STATUS_CONFIG[expense.status] || STATUS_CONFIG.due
|
||||||
|
const catLabel = CATEGORIES.find(c => c.id === expense.category)?.label || expense.category
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer', background: isExpanded ? '#f9fafb' : 'white' }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<div style={{ fontWeight: 600, color: '#111315', fontSize: 13.5 }}>{expense.description}</div>
|
||||||
|
{expense.contact_name && <div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>{expense.contact_name}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 12, color: '#6b7280' }}>{catLabel}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 600, color: '#111315', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{fmt(expense.total_amount)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 13, color: '#16a34a', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.paid_amount > 0 ? fmt(expense.paid_amount) : '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 700, color: '#dc2626', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.due_amount > 0 ? fmt(expense.due_amount) : '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', fontSize: 12, color: '#9ca3af' }}>{expense.due_date ? fmtDate(expense.due_date) : '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
|
||||||
|
{expense.status !== 'paid' && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onPay() }}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #bbf7d0', background: '#f0fdf4', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#16a34a' }}
|
||||||
|
>
|
||||||
|
Πληρωμή
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||||
|
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}
|
||||||
|
>
|
||||||
|
Επεξ.
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<tr style={{ background: '#f9fafb' }}>
|
||||||
|
<td colSpan={8} style={{ padding: '12px 16px 16px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{expense.notes && (
|
||||||
|
<p style={{ margin: 0, fontSize: 12.5, color: '#6b7280', fontStyle: 'italic' }}>📝 {expense.notes}</p>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11.5, color: '#9ca3af' }}>
|
||||||
|
Καταχωρήθηκε από {expense.created_by_name} · {fmtDate(expense.created_at)}
|
||||||
|
</div>
|
||||||
|
{expense.payments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Ιστορικό Πληρωμών</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{expense.payments.map(p => (
|
||||||
|
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', background: 'white', borderRadius: 8, border: '1px solid #e5e7eb' }}>
|
||||||
|
<span style={{ fontSize: 12.5, color: '#374151' }}>
|
||||||
|
{p.paid_by_name} · {fmtDateTime(p.paid_at)}
|
||||||
|
{p.notes && <span style={{ color: '#9ca3af' }}> — {p.notes}</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a' }}>{fmt(p.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expense.payments.length === 0 && expense.status === 'due' && (
|
||||||
|
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν έχουν καταγραφεί πληρωμές.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ExpensesPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||||
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
const [modal, setModal] = useState(null) // null | { type: 'expense', expense? } | { type: 'pay', expense }
|
||||||
|
|
||||||
|
const params = {}
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
if (categoryFilter !== 'all') params.category = categoryFilter
|
||||||
|
|
||||||
|
const { data: expenses = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['expenses', statusFilter, categoryFilter],
|
||||||
|
queryFn: () => client.get('/api/expenses/', { params }).then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: contacts = [] } = useQuery({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
queryFn: () => client.get('/api/contacts/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: ['expenses-summary'],
|
||||||
|
queryFn: () => client.get('/api/expenses/summary').then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveExpense = useMutation({
|
||||||
|
mutationFn: ({ form, id }) => id
|
||||||
|
? client.put(`/api/expenses/${id}`, form)
|
||||||
|
: client.post('/api/expenses/', form),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Αποθηκεύτηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payExpense = useMutation({
|
||||||
|
mutationFn: ({ id, amount, notes }) => client.post(`/api/expenses/${id}/pay`, { amount, notes }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Πληρωμή καταγράφηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα καταγραφής πληρωμής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteExpense = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/expenses/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
|
||||||
|
toast.success('Διαγράφηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalDue = summary?.total_due ?? 0
|
||||||
|
const pendingCount = expenses.filter(e => e.status !== 'paid').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Έξοδα</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal({ type: 'expense', expense: null })}
|
||||||
|
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
+ Νέο Έξοδο
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{/* Status filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{[['all', 'Όλα'], ['due', 'Οφείλεται'], ['partial', 'Μερική'], ['paid', 'Πληρώθηκε']].map(([v, l]) => (
|
||||||
|
<button key={v} onClick={() => setStatusFilter(v)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: '1.5px solid',
|
||||||
|
borderColor: statusFilter === v ? '#111827' : '#e5e7eb',
|
||||||
|
background: statusFilter === v ? '#111827' : 'white',
|
||||||
|
color: statusFilter === v ? 'white' : '#6b7280',
|
||||||
|
}}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Category filter */}
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
|
style={{ padding: '5px 10px', borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12, outline: 'none', background: 'white', color: '#374151' }}
|
||||||
|
>
|
||||||
|
<option value="all">Όλες οι κατηγορίες</option>
|
||||||
|
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px' }}>
|
||||||
|
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && expenses.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
|
||||||
|
Δεν βρέθηκαν έξοδα για τα επιλεγμένα φίλτρα.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expenses.length > 0 && (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
{['Περιγραφή', 'Κατηγορία', 'Σύνολο', 'Πληρωμένο', 'Εκκρεμεί', 'Κατάσταση', 'Ημ/νία', ''].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 16px', textAlign: h === 'Σύνολο' || h === 'Πληρωμένο' || h === 'Εκκρεμεί' ? 'right' : 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{expenses.map(e => (
|
||||||
|
<ExpenseRow
|
||||||
|
key={e.id}
|
||||||
|
expense={e}
|
||||||
|
isExpanded={expanded === e.id}
|
||||||
|
onToggle={() => setExpanded(expanded === e.id ? null : e.id)}
|
||||||
|
onPay={() => setModal({ type: 'pay', expense: e })}
|
||||||
|
onEdit={() => setModal({ type: 'expense', expense: e })}
|
||||||
|
onDelete={() => { if (window.confirm('Διαγραφή εξόδου;')) deleteExpense.mutate(e.id) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expense create/edit modal */}
|
||||||
|
{modal?.type === 'expense' && (
|
||||||
|
<ExpenseModal
|
||||||
|
initial={modal.expense ? {
|
||||||
|
...modal.expense,
|
||||||
|
contact_id: modal.expense.contact_id ? String(modal.expense.contact_id) : '',
|
||||||
|
due_date: modal.expense.due_date || '',
|
||||||
|
notes: modal.expense.notes || '',
|
||||||
|
} : null}
|
||||||
|
contacts={contacts}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={saveExpense.isPending}
|
||||||
|
onSave={(form) => saveExpense.mutate({
|
||||||
|
form: {
|
||||||
|
description: form.description,
|
||||||
|
category: form.category,
|
||||||
|
contact_id: form.contact_id ? Number(form.contact_id) : null,
|
||||||
|
total_amount: parseFloat(form.total_amount),
|
||||||
|
due_date: form.due_date || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
},
|
||||||
|
id: modal.expense?.id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pay modal */}
|
||||||
|
{modal?.type === 'pay' && (
|
||||||
|
<PayModal
|
||||||
|
expense={modal.expense}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={payExpense.isPending}
|
||||||
|
onPay={(amount, notes) => payExpense.mutate({ id: modal.expense.id, amount, notes })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
manager_dashboard/src/pages/KdsPage.jsx
Normal file
278
manager_dashboard/src/pages/KdsPage.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeSince(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||||
|
if (mins < 1) return '< 1λ'
|
||||||
|
if (mins < 60) return `${mins}λ`
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyColor(iso) {
|
||||||
|
if (!iso) return '#111827'
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||||
|
if (mins >= 15) return '#dc2626' // red — very late
|
||||||
|
if (mins >= 8) return '#d97706' // amber — getting late
|
||||||
|
return '#111827' // normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ItemCard({ item, onMarkReady, isMarking }) {
|
||||||
|
const [, forceUpdate] = useState(0)
|
||||||
|
|
||||||
|
// Rerender every 30s so elapsed time stays fresh
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => forceUpdate(n => n + 1), 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const elapsed = timeSince(item.added_at)
|
||||||
|
const timeColor = urgencyColor(item.added_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => !isMarking && onMarkReady(item)}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
cursor: isMarking ? 'default' : 'pointer',
|
||||||
|
opacity: isMarking ? 0.6 : 1,
|
||||||
|
transition: 'opacity 200ms, box-shadow 150ms',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
className="hover:shadow-md active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{/* Table + time */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 800, color: 'white',
|
||||||
|
background: '#111827', padding: '2px 9px', borderRadius: 20,
|
||||||
|
}}>
|
||||||
|
{item.table_name ?? `#${item.order_id}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: timeColor }}>
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product name + qty */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 800, color: '#111827', lineHeight: 1.2 }}>
|
||||||
|
{item.product_name}
|
||||||
|
</span>
|
||||||
|
{item.quantity > 1 && (
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#6b7280' }}>
|
||||||
|
×{item.quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{item.notes && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 6, fontSize: 12.5, color: '#d97706',
|
||||||
|
background: '#fffbeb', borderRadius: 6, padding: '4px 8px',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}>
|
||||||
|
{item.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tap hint */}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10.5, color: '#9ca3af', textAlign: 'right' }}>
|
||||||
|
Πατήστε για ✓ Έτοιμο
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zone column ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ZoneColumn({ zone, onMarkReady, markingIds }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 0,
|
||||||
|
background: '#f9fafb', borderRadius: 14, overflow: 'hidden',
|
||||||
|
border: '1px solid #e5e7eb', minWidth: 260, flex: '1 1 260px',
|
||||||
|
}}>
|
||||||
|
{/* Zone header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px', background: '#111827',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: 'white', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{zone.zone_name}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 700, background: '#374151',
|
||||||
|
color: 'white', padding: '2px 8px', borderRadius: 99,
|
||||||
|
}}>
|
||||||
|
{zone.items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, overflowY: 'auto', padding: 10,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
}}>
|
||||||
|
{zone.items.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '24px 0' }}>
|
||||||
|
Όλα έτοιμα ✓
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
zone.items.map(item => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkReady={onMarkReady}
|
||||||
|
isMarking={markingIds.has(item.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main KDS page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function KdsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
const [markingIds, setMarkingIds] = useState(new Set())
|
||||||
|
const [clock, setClock] = useState(new Date())
|
||||||
|
const esRef = useRef(null)
|
||||||
|
|
||||||
|
// Clock tick
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setClock(new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['kds-items'],
|
||||||
|
queryFn: () => client.get('/api/kds/items').then(r => r.data),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: 30_000, // fallback poll if SSE drops
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE — refetch on any order_updated or item_status_changed event
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return
|
||||||
|
const es = new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
|
||||||
|
esRef.current = es
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const { type } = JSON.parse(e.data)
|
||||||
|
if (['order_updated', 'item_status_changed', 'order_paid', 'order_closed'].includes(type)) {
|
||||||
|
qc.invalidateQueries({ queryKey: ['kds-items'] })
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return () => { es.close(); esRef.current = null }
|
||||||
|
}, [token, qc])
|
||||||
|
|
||||||
|
const markReady = useMutation({
|
||||||
|
mutationFn: ({ order_id, item_id }) =>
|
||||||
|
client.put(`/api/kds/orders/${order_id}/items/${item_id}/status`, { status: 'ready' }),
|
||||||
|
onMutate: ({ item_id }) => {
|
||||||
|
setMarkingIds(s => new Set([...s, item_id]))
|
||||||
|
},
|
||||||
|
onSuccess: (_, { item_id }) => {
|
||||||
|
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
|
||||||
|
qc.invalidateQueries({ queryKey: ['kds-items'] })
|
||||||
|
},
|
||||||
|
onError: (e, { item_id }) => {
|
||||||
|
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
|
||||||
|
toast.error(e?.response?.data?.detail || 'Σφάλμα')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const zones = data?.zones ?? []
|
||||||
|
const totalPending = zones.reduce((s, z) => s + z.items.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
background: '#111827', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '10px 20px', background: '#0f172a', flexShrink: 0,
|
||||||
|
borderBottom: '1px solid #1e293b',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>
|
||||||
|
KDS — Κουζίνα
|
||||||
|
</span>
|
||||||
|
{totalPending > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, fontWeight: 700, padding: '3px 10px', borderRadius: 20,
|
||||||
|
background: totalPending >= 10 ? '#dc2626' : '#d97706',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{totalPending} εκκρεμή
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalPending === 0 && !isLoading && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#22c55e' }}>✓ Όλα έτοιμα</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#94a3b8', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
style={{
|
||||||
|
fontSize: 12, fontWeight: 600, padding: '5px 12px', borderRadius: 7,
|
||||||
|
border: '1px solid #334155', background: '#1e293b',
|
||||||
|
color: '#94a3b8', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ Ανανέωση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone columns */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, overflowX: 'auto', overflowY: 'hidden',
|
||||||
|
padding: '14px 16px',
|
||||||
|
display: 'flex', gap: 12, alignItems: 'stretch',
|
||||||
|
}}>
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto' }}>Φόρτωση…</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && zones.length === 0 && (
|
||||||
|
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>✓</div>
|
||||||
|
Δεν υπάρχουν εκκρεμείς παραγγελίες.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zones.map(zone => (
|
||||||
|
<ZoneColumn
|
||||||
|
key={zone.zone_id ?? 'no-zone'}
|
||||||
|
zone={zone}
|
||||||
|
onMarkReady={(item) => markReady.mutate({ order_id: item.order_id, item_id: item.id })}
|
||||||
|
markingIds={markingIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -139,6 +139,19 @@ function buildFormFromProduct(product) {
|
|||||||
} : null,
|
} : null,
|
||||||
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
|
digital_visible: product.digital_visible ?? true,
|
||||||
|
digital_available: product.digital_available ?? true,
|
||||||
|
digital_name: product.digital_name ?? '',
|
||||||
|
digital_description: product.digital_description ?? '',
|
||||||
|
digital_image_url: product.digital_image_url ?? '',
|
||||||
|
digital_price: product.digital_price ?? '',
|
||||||
|
digital_discount: product.digital_discount ?? '',
|
||||||
|
// Phase 2A — cost tracking
|
||||||
|
cost_mode: product.cost_breakdown?.length ? 'detailed' : 'simple',
|
||||||
|
cost_simple: product.cost_simple ?? '',
|
||||||
|
cost_breakdown: product.cost_breakdown?.length
|
||||||
|
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +192,7 @@ function getItemTypeLabel(type) {
|
|||||||
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
|
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
|
||||||
const [form, setForm] = useState(() => buildFormFromProduct(product))
|
const [form, setForm] = useState(() => buildFormFromProduct(product))
|
||||||
const [activeTab, setActiveTab] = useState('favorites')
|
const [activeTab, setActiveTab] = useState('favorites')
|
||||||
|
const [leftTab, setLeftTab] = useState('info')
|
||||||
const [imageFile, setImageFile] = useState(null)
|
const [imageFile, setImageFile] = useState(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -192,6 +206,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setForm(buildFormFromProduct(product))
|
setForm(buildFormFromProduct(product))
|
||||||
setActiveTab('favorites')
|
setActiveTab('favorites')
|
||||||
|
setLeftTab('info')
|
||||||
setImageFile(null)
|
setImageFile(null)
|
||||||
}, [product.id, product.name])
|
}, [product.id, product.name])
|
||||||
|
|
||||||
@@ -425,6 +440,18 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
})),
|
})),
|
||||||
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
||||||
})),
|
})),
|
||||||
|
digital_visible: form.digital_visible,
|
||||||
|
digital_available: form.digital_available,
|
||||||
|
digital_name: form.digital_name || null,
|
||||||
|
digital_description: form.digital_description || null,
|
||||||
|
digital_image_url: form.digital_image_url || null,
|
||||||
|
digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null,
|
||||||
|
digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null,
|
||||||
|
// Phase 2A — cost tracking
|
||||||
|
cost_simple: form.cost_mode === 'simple' && form.cost_simple !== '' ? parseFloat(form.cost_simple) : null,
|
||||||
|
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
|
||||||
|
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,16 +508,36 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
{/* LEFT: product info */}
|
{/* LEFT: tabbed product info — 30% width */}
|
||||||
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3 overflow-y-auto">
|
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: '25%' }}>
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
|
{/* Left tab bar */}
|
||||||
|
<div className="flex border-b border-gray-200 shrink-0 bg-white">
|
||||||
|
{[
|
||||||
|
{ key: 'info', label: 'Πληροφορίες' },
|
||||||
|
{ key: 'digital', label: 'Digital Menu' },
|
||||||
|
{ key: 'cost', label: 'Κόστος' },
|
||||||
|
].map(t => (
|
||||||
|
<button key={t.key} onClick={() => setLeftTab(t.key)}
|
||||||
|
className={`flex-1 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||||
|
leftTab === t.key
|
||||||
|
? 'border-primary-600 text-primary-700 bg-primary-50/50'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left tab content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-3">
|
||||||
|
|
||||||
|
{/* Tab 1: Main Info */}
|
||||||
|
{leftTab === 'info' && (<>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Όνομα *</label>
|
<label className="label">Όνομα *</label>
|
||||||
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
|
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description — optional, for digital menus / staff info */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
|
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -505,7 +552,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Τιμή βάσης (€) *</label>
|
<label className="label">Βασική Τιμή (€) *</label>
|
||||||
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -540,7 +587,6 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Image upload */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Εικόνα προϊόντος</label>
|
<label className="label">Εικόνα προϊόντος</label>
|
||||||
{product.image_url && (
|
{product.image_url && (
|
||||||
@@ -554,10 +600,172 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
|||||||
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
|
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* Tab 2: Digital Menu */}
|
||||||
|
{leftTab === 'digital' && (() => {
|
||||||
|
const basePrice = parseFloat(form.base_price) || 0
|
||||||
|
const dp = form.digital_price !== '' ? parseFloat(form.digital_price) : null
|
||||||
|
const disc = form.digital_discount !== '' ? parseFloat(form.digital_discount) : null
|
||||||
|
let customerPrice = basePrice
|
||||||
|
if (dp !== null && !isNaN(dp)) customerPrice = dp
|
||||||
|
else if (disc !== null && !isNaN(disc) && disc > 0) customerPrice = Math.round(basePrice * (1 - disc / 100) * 100) / 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Ορατότητα</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={form.digital_visible}
|
||||||
|
onChange={e => setField('digital_visible', e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-primary-700" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Show on digital menu</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={form.digital_available}
|
||||||
|
onChange={e => setField('digital_available', e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-primary-700" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Available for ordering</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 ml-7">Uncheck to show as ‘Out of stock’</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: tabs */}
|
<div>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">Display overrides</p>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">Leave blank to use the standard product value</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Display name (override)</label>
|
||||||
|
<input className="input" value={form.digital_name}
|
||||||
|
onChange={e => setField('digital_name', e.target.value)}
|
||||||
|
placeholder={form.name || 'Leave blank to use product name'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Description</label>
|
||||||
|
<textarea className="input resize-none" rows={3}
|
||||||
|
value={form.digital_description}
|
||||||
|
onChange={e => setField('digital_description', e.target.value)}
|
||||||
|
placeholder="Leave blank to use product description"
|
||||||
|
style={{ lineHeight: 1.5, fontSize: 13 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Image URL</label>
|
||||||
|
<input className="input" value={form.digital_image_url}
|
||||||
|
onChange={e => setField('digital_image_url', e.target.value)}
|
||||||
|
placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Pricing</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Online price (override)</label>
|
||||||
|
<input type="number" step="0.01" min="0" className="input w-40"
|
||||||
|
value={form.digital_price}
|
||||||
|
onChange={e => setField('digital_price', e.target.value)}
|
||||||
|
placeholder="e.g. 9.50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Discount %</label>
|
||||||
|
<input type="number" step="1" min="0" max="100" className="input w-40"
|
||||||
|
value={form.digital_discount}
|
||||||
|
onChange={e => setField('digital_discount', e.target.value)}
|
||||||
|
placeholder="e.g. 10 for 10% off" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800">
|
||||||
|
Customer sees: <span className="font-bold">€{customerPrice.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Tab 3: Cost */}
|
||||||
|
{leftTab === 'cost' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-gray-400">Το κόστος καταγράφεται στιγμιότυπα σε κάθε παραγγελία για υπολογισμό κέρδους.</p>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Τύπος κόστους</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['simple', 'detailed'].map(mode => (
|
||||||
|
<button key={mode} type="button"
|
||||||
|
onClick={() => setField('cost_mode', mode)}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||||
|
form.cost_mode === mode
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
{mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{form.cost_mode === 'simple' ? (
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Κόστος ανά μονάδα (€)</label>
|
||||||
|
<PriceInput value={form.cost_simple} onChange={v => setField('cost_simple', v)} className="w-full" />
|
||||||
|
{form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
|
||||||
|
const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
|
||||||
|
const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
|
||||||
|
return (
|
||||||
|
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
|
||||||
|
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.cost_breakdown.map((entry, bi) => (
|
||||||
|
<div key={bi} className="flex gap-2 items-center">
|
||||||
|
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
|
||||||
|
value={entry.label}
|
||||||
|
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
|
||||||
|
<PriceInput value={entry.amount}
|
||||||
|
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
|
||||||
|
className="w-28" />
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
|
||||||
|
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
|
||||||
|
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
|
||||||
|
{form.cost_breakdown.length > 0 && (() => {
|
||||||
|
const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
|
||||||
|
const price = parseFloat(form.base_price) || 0
|
||||||
|
const isError = total > price && price > 0
|
||||||
|
const margin = price > 0 ? ((price - total) / price * 100) : null
|
||||||
|
return (
|
||||||
|
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
|
||||||
|
Σύνολο: €{total.toFixed(2)}
|
||||||
|
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
|
||||||
|
{isError && <span className="ml-1">— Κόστος > Τιμή!</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: ingredient/option tabs — 70% */}
|
||||||
|
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
|
||||||
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
|
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
if (tab.isAdd) return (
|
if (tab.isAdd) return (
|
||||||
|
|||||||
@@ -636,7 +636,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk bar ──────────────────────────────────────────────────────────────────
|
// ── Bulk bar ──────────────────────────────────────────────────────────────────
|
||||||
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -645,7 +645,7 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
|||||||
</button>
|
</button>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
|
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
|
||||||
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
|
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
|
||||||
@@ -659,6 +659,111 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
|
||||||
|
<button onClick={onSetPrinterZone}
|
||||||
|
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
className="hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Icon name="folder" size={13} />
|
||||||
|
Ζώνη εκτύπωσης
|
||||||
|
</button>
|
||||||
|
<button onClick={onMoveToCategory}
|
||||||
|
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
className="hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Icon name="move" size={13} />
|
||||||
|
Μετακίνηση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk: Set Printer Zone modal ───────────────────────────────────────────────
|
||||||
|
function BulkSetPrinterZoneModal({ count, printers, onClose, onConfirm }) {
|
||||||
|
const zones = [...new Map(printers.map(p => [p.id, p])).values()]
|
||||||
|
const [selectedId, setSelectedId] = useState(zones[0]?.id ?? '')
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 380, padding: 24 }}>
|
||||||
|
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνη εκτύπωσης</h2>
|
||||||
|
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
|
||||||
|
Θα οριστεί σε <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
|
||||||
|
</p>
|
||||||
|
{zones.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν εκτυπωτές.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
||||||
|
{zones.map(z => (
|
||||||
|
<label key={z.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${selectedId === z.id ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: selectedId === z.id ? '#eff6ff' : '#fff' }}>
|
||||||
|
<input type="radio" name="zone" value={z.id} checked={selectedId === z.id} onChange={() => setSelectedId(z.id)} style={{ display: 'none' }} />
|
||||||
|
<span style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selectedId === z.id ? '#3b82f6' : '#d1d5db'}`, background: selectedId === z.id ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
{selectedId === z.id && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff' }} />}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onConfirm(selectedId)}
|
||||||
|
disabled={!selectedId}
|
||||||
|
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
|
||||||
|
>
|
||||||
|
Εφαρμογή
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk: Move to Category modal ───────────────────────────────────────────────
|
||||||
|
function BulkMoveToCategoryModal({ count, categories, onClose, onConfirm }) {
|
||||||
|
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
const [selectedId, setSelectedId] = useState('')
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 400, padding: 24 }}>
|
||||||
|
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Μετακίνηση σε κατηγορία</h2>
|
||||||
|
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
|
||||||
|
Θα μετακινηθούν <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
|
||||||
|
</p>
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 20 }}>
|
||||||
|
{topLevel.map(cat => {
|
||||||
|
const subs = categories.filter(c => c.parent_id === cat.id).sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
return (
|
||||||
|
<div key={cat.id}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', border: `1.5px solid ${selectedId === cat.id ? cat.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 9, cursor: 'pointer', background: selectedId === cat.id ? '#f8faff' : '#fff' }}>
|
||||||
|
<input type="radio" name="cat" value={cat.id} checked={selectedId === cat.id} onChange={() => setSelectedId(cat.id)} style={{ display: 'none' }} />
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cat.color || '#94a3b8', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 13.5, fontWeight: 600, color: '#111827', flex: 1 }}>{cat.name}</span>
|
||||||
|
{selectedId === cat.id && <Icon name="check" size={14} className="text-blue-500" />}
|
||||||
|
</label>
|
||||||
|
{subs.map(sub => (
|
||||||
|
<label key={sub.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px 8px 28px', border: `1.5px solid ${selectedId === sub.id ? sub.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 8, cursor: 'pointer', background: selectedId === sub.id ? '#f8faff' : '#fff', marginTop: 3 }}>
|
||||||
|
<input type="radio" name="cat" value={sub.id} checked={selectedId === sub.id} onChange={() => setSelectedId(sub.id)} style={{ display: 'none' }} />
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: '50%', background: sub.color || '#94a3b8', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: '#374151', flex: 1 }}>{sub.name}</span>
|
||||||
|
{selectedId === sub.id && <Icon name="check" size={13} className="text-blue-500" />}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onConfirm(selectedId)}
|
||||||
|
disabled={!selectedId}
|
||||||
|
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
|
||||||
|
>
|
||||||
|
Μετακίνηση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -740,10 +845,29 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price + cost/margin */}
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', marginRight: 4, whiteSpace: 'nowrap' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginRight: 4, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', whiteSpace: 'nowrap' }}>
|
||||||
€{parseFloat(p.base_price).toFixed(2)}
|
€{parseFloat(p.base_price).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const effectiveCost = p.cost_breakdown?.length
|
||||||
|
? p.cost_breakdown.reduce((s, e) => s + (e.amount || 0), 0)
|
||||||
|
: (p.cost_simple ?? null)
|
||||||
|
if (effectiveCost === null) return null
|
||||||
|
const price = parseFloat(p.base_price)
|
||||||
|
const isError = effectiveCost > price
|
||||||
|
const margin = price > 0 ? ((price - effectiveCost) / price * 100) : null
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||||
|
color: isError ? '#ef4444' : '#16a34a',
|
||||||
|
}}>
|
||||||
|
{isError ? '⚠ κόστος > τιμή' : margin !== null ? `${margin.toFixed(0)}% margin` : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
||||||
@@ -779,10 +903,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
|||||||
Επεξεργασία
|
Επεξεργασία
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete — pill style */}
|
{/* Delete — icon only, same height as Edit pill */}
|
||||||
<button onClick={e => { e.stopPropagation(); onArchive(p) }}
|
<button onClick={e => { e.stopPropagation(); onArchive(p) }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px',
|
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 10px',
|
||||||
|
height: 27,
|
||||||
background: '#fff5f5', border: '1px solid #fecaca',
|
background: '#fff5f5', border: '1px solid #fecaca',
|
||||||
borderRadius: 999, fontSize: 11.5, fontWeight: 500, color: '#ef4444',
|
borderRadius: 999, fontSize: 11.5, fontWeight: 500, color: '#ef4444',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -790,7 +915,6 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
|||||||
className="hover:bg-red-100"
|
className="hover:bg-red-100"
|
||||||
title="Διαγραφή">
|
title="Διαγραφή">
|
||||||
<Icon name="trash" size={13} />
|
<Icon name="trash" size={13} />
|
||||||
Διαγραφή
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -938,6 +1062,8 @@ export default function ProductsTab() {
|
|||||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
|
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
|
||||||
// local override of group display order (array of group keys), reset when scope changes
|
// local override of group display order (array of group keys), reset when scope changes
|
||||||
const [localGroupOrder, setLocalGroupOrder] = useState(null)
|
const [localGroupOrder, setLocalGroupOrder] = useState(null)
|
||||||
|
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
|
||||||
|
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
|
||||||
|
|
||||||
// drag state for group headers
|
// drag state for group headers
|
||||||
const groupDragId = useRef(null)
|
const groupDragId = useRef(null)
|
||||||
@@ -1163,6 +1289,26 @@ export default function ProductsTab() {
|
|||||||
setConfirmDelete(null); invalidate(); clearSelect()
|
setConfirmDelete(null); invalidate(); clearSelect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBulkSetPrinterZone(printerId) {
|
||||||
|
const ids = [...selected]
|
||||||
|
setBulkPrinterZoneModal(false)
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
|
||||||
|
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
|
||||||
|
invalidate(); clearSelect()
|
||||||
|
} catch { toast.error('Σφάλμα') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkMoveToCategory(categoryId) {
|
||||||
|
const ids = [...selected]
|
||||||
|
setBulkMoveCatModal(false)
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { category_id: categoryId })))
|
||||||
|
toast.success(`${ids.length} προϊόντα μετακινήθηκαν`)
|
||||||
|
invalidate(); clearSelect()
|
||||||
|
} catch { toast.error('Σφάλμα') }
|
||||||
|
}
|
||||||
|
|
||||||
function handleConfirmDelete() {
|
function handleConfirmDelete() {
|
||||||
if (!confirmDelete) return
|
if (!confirmDelete) return
|
||||||
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
|
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
|
||||||
@@ -1345,6 +1491,8 @@ export default function ProductsTab() {
|
|||||||
onAvail={() => bulkAction('available')}
|
onAvail={() => bulkAction('available')}
|
||||||
onUnavail={() => bulkAction('unavailable')}
|
onUnavail={() => bulkAction('unavailable')}
|
||||||
onArchive={() => bulkAction('archive')}
|
onArchive={() => bulkAction('archive')}
|
||||||
|
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
|
||||||
|
onMoveToCategory={() => setBulkMoveCatModal(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1500,6 +1648,26 @@ export default function ProductsTab() {
|
|||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bulk: set printer zone modal */}
|
||||||
|
{bulkPrinterZoneModal && (
|
||||||
|
<BulkSetPrinterZoneModal
|
||||||
|
count={selected.size}
|
||||||
|
printers={printers}
|
||||||
|
onClose={() => setBulkPrinterZoneModal(false)}
|
||||||
|
onConfirm={handleBulkSetPrinterZone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk: move to category modal */}
|
||||||
|
{bulkMoveCatModal && (
|
||||||
|
<BulkMoveToCategoryModal
|
||||||
|
count={selected.size}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setBulkMoveCatModal(false)}
|
||||||
|
onConfirm={handleBulkMoveToCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
501
manager_dashboard/src/pages/NotesPage.jsx
Normal file
501
manager_dashboard/src/pages/NotesPage.jsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now - d
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
if (diffMins < 1) return 'μόλις τώρα'
|
||||||
|
if (diffMins < 60) return `${diffMins}λ πριν`
|
||||||
|
const diffH = Math.floor(diffMins / 60)
|
||||||
|
if (diffH < 24) return `${diffH}ω πριν`
|
||||||
|
return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes column ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NoteCard({ note, onSave, onDelete, onTogglePin }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(note.body)
|
||||||
|
const taRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && taRef.current) {
|
||||||
|
taRef.current.focus()
|
||||||
|
taRef.current.selectionStart = taRef.current.value.length
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (draft.trim() === note.body) { setEditing(false); return }
|
||||||
|
if (!draft.trim()) { setEditing(false); setDraft(note.body); return }
|
||||||
|
onSave(note.id, draft.trim())
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Escape') { setEditing(false); setDraft(note.body) }
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleBlur()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: note.is_pinned ? '1.5px solid #fbbf24' : '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: note.is_pinned ? '#fffbeb' : 'white',
|
||||||
|
padding: '14px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
ref={taRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
width: '100%', minHeight: 80, border: '1.5px solid #3b82f6',
|
||||||
|
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, lineHeight: 1.55,
|
||||||
|
resize: 'vertical', outline: 'none', fontFamily: 'inherit',
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
onClick={() => { setEditing(true); setDraft(note.body) }}
|
||||||
|
style={{
|
||||||
|
fontSize: 13.5, color: '#111827', lineHeight: 1.6, cursor: 'text',
|
||||||
|
whiteSpace: 'pre-wrap', margin: 0, wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||||
|
{note.created_by_name} · {fmtDate(note.updated_at)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onTogglePin(note.id, !note.is_pinned)}
|
||||||
|
title={note.is_pinned ? 'Αποσύνδεση' : 'Καρφίτσωμα'}
|
||||||
|
style={{
|
||||||
|
fontSize: 13, padding: '2px 7px', borderRadius: 6, border: '1px solid #e5e7eb',
|
||||||
|
background: note.is_pinned ? '#fef3c7' : 'white', cursor: 'pointer',
|
||||||
|
color: note.is_pinned ? '#d97706' : '#6b7280',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.is_pinned ? '📌' : '📍'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm('Διαγραφή σημείωσης;')) onDelete(note.id) }}
|
||||||
|
style={{
|
||||||
|
fontSize: 12, padding: '2px 7px', borderRadius: 6,
|
||||||
|
border: '1px solid #fecaca', background: 'white',
|
||||||
|
color: '#ef4444', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotesColumn({ notes, onAdd, onSave, onDelete, onTogglePin }) {
|
||||||
|
const [newBody, setNewBody] = useState('')
|
||||||
|
const [newPinned, setNewPinned] = useState(false)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!newBody.trim()) return
|
||||||
|
onAdd(newBody.trim(), newPinned)
|
||||||
|
setNewBody('')
|
||||||
|
setNewPinned(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinned = notes.filter(n => n.is_pinned)
|
||||||
|
const rest = notes.filter(n => !n.is_pinned)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
|
||||||
|
{/* New note form */}
|
||||||
|
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
|
||||||
|
<textarea
|
||||||
|
value={newBody}
|
||||||
|
onChange={e => setNewBody(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Νέα σημείωση… (Ctrl+Enter για αποθήκευση)"
|
||||||
|
style={{
|
||||||
|
width: '100%', minHeight: 72, border: 'none', outline: 'none',
|
||||||
|
background: 'transparent', fontSize: 13.5, lineHeight: 1.55,
|
||||||
|
resize: 'none', fontFamily: 'inherit', color: '#374151',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#6b7280', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={newPinned} onChange={e => setNewPinned(e.target.checked)} />
|
||||||
|
Καρφίτσωμα
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!newBody.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px', borderRadius: 7, border: 'none',
|
||||||
|
background: newBody.trim() ? '#111827' : '#e5e7eb',
|
||||||
|
color: newBody.trim() ? 'white' : '#9ca3af',
|
||||||
|
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Αποθήκευση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{pinned.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#d97706', textTransform: 'uppercase', letterSpacing: '0.06em' }}>📌 Καρφιτσωμένες</div>
|
||||||
|
{pinned.map(n => (
|
||||||
|
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<>
|
||||||
|
{pinned.length > 0 && <div style={{ fontSize: 10, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginTop: 4 }}>Πρόσφατες</div>}
|
||||||
|
{rest.map(n => (
|
||||||
|
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
|
||||||
|
Δεν υπάρχουν σημειώσεις ακόμα.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Todos column ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(todo.body)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && inputRef.current) inputRef.current.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
if (!draft.trim() || draft.trim() === todo.body) { setEditing(false); setDraft(todo.body); return }
|
||||||
|
onEdit(todo.id, draft.trim())
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||||
|
padding: '10px 12px', borderRadius: 10,
|
||||||
|
border: `1px solid ${todo.is_done ? '#e5e7eb' : todo.priority === 'high' ? '#fca5a5' : '#e5e7eb'}`,
|
||||||
|
background: todo.is_done ? '#fafafa' : todo.priority === 'high' ? '#fff5f5' : 'white',
|
||||||
|
opacity: todo.is_done ? 0.65 : 1,
|
||||||
|
}}>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(todo.id, !todo.is_done)}
|
||||||
|
style={{
|
||||||
|
width: 20, height: 20, borderRadius: 5, flexShrink: 0, marginTop: 1,
|
||||||
|
border: `2px solid ${todo.is_done ? '#22c55e' : todo.priority === 'high' ? '#ef4444' : '#d1d5db'}`,
|
||||||
|
background: todo.is_done ? '#22c55e' : 'white',
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.is_done && <span style={{ color: 'white', fontSize: 11, lineHeight: 1 }}>✓</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') { setEditing(false); setDraft(todo.body) } }}
|
||||||
|
style={{
|
||||||
|
width: '100%', border: '1.5px solid #3b82f6', borderRadius: 6,
|
||||||
|
padding: '3px 7px', fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
onClick={() => { if (!todo.is_done) { setEditing(true); setDraft(todo.body) } }}
|
||||||
|
style={{
|
||||||
|
margin: 0, fontSize: 13.5, lineHeight: 1.5, wordBreak: 'break-word',
|
||||||
|
color: todo.is_done ? '#9ca3af' : '#111827',
|
||||||
|
textDecoration: todo.is_done ? 'line-through' : 'none',
|
||||||
|
cursor: todo.is_done ? 'default' : 'text',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.priority === 'high' && !todo.is_done && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', marginRight: 5 }}>● ΕΠΕΙΓΟΝ</span>
|
||||||
|
)}
|
||||||
|
{todo.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 3 }}>
|
||||||
|
{todo.created_by_name} · {fmtDate(todo.created_at)}
|
||||||
|
{todo.is_done && todo.done_by_name && (
|
||||||
|
<span style={{ color: '#22c55e' }}> · ✓ {todo.done_by_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm('Διαγραφή εργασίας;')) onDelete(todo.id) }}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, padding: '2px 6px', borderRadius: 5, flexShrink: 0,
|
||||||
|
border: '1px solid #fecaca', background: 'white', color: '#ef4444', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodosColumn({ todos, onAdd, onToggle, onDelete, onEdit }) {
|
||||||
|
const [newBody, setNewBody] = useState('')
|
||||||
|
const [newPriority, setNewPriority] = useState('normal')
|
||||||
|
const [showDone, setShowDone] = useState(false)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!newBody.trim()) return
|
||||||
|
onAdd(newBody.trim(), newPriority)
|
||||||
|
setNewBody('')
|
||||||
|
setNewPriority('normal')
|
||||||
|
}
|
||||||
|
|
||||||
|
const undone = todos.filter(t => !t.is_done)
|
||||||
|
const done = todos.filter(t => t.is_done)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
|
||||||
|
{/* New todo form */}
|
||||||
|
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
|
||||||
|
<input
|
||||||
|
value={newBody}
|
||||||
|
onChange={e => setNewBody(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') submit() }}
|
||||||
|
placeholder="Νέα εργασία… (Enter)"
|
||||||
|
style={{
|
||||||
|
width: '100%', border: 'none', outline: 'none', background: 'transparent',
|
||||||
|
fontSize: 13.5, fontFamily: 'inherit', color: '#374151',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{['normal', 'high'].map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setNewPriority(p)}
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px', borderRadius: 6, fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1.5px solid ${newPriority === p ? (p === 'high' ? '#ef4444' : '#6b7280') : '#e5e7eb'}`,
|
||||||
|
background: newPriority === p ? (p === 'high' ? '#fee2e2' : '#f3f4f6') : 'white',
|
||||||
|
color: newPriority === p ? (p === 'high' ? '#ef4444' : '#374151') : '#9ca3af',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p === 'high' ? '● Επείγον' : 'Κανονικό'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!newBody.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px', borderRadius: 7, border: 'none',
|
||||||
|
background: newBody.trim() ? '#111827' : '#e5e7eb',
|
||||||
|
color: newBody.trim() ? 'white' : '#9ca3af',
|
||||||
|
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Προσθήκη
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todo list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{undone.length === 0 && done.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
|
||||||
|
Δεν υπάρχουν εργασίες ακόμα.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{undone.map(t => (
|
||||||
|
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{done.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDone(s => !s)}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, fontSize: 12, color: '#9ca3af', fontWeight: 600,
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showDone ? '▾' : '▸'} Ολοκληρωμένες ({done.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDone && done.map(t => (
|
||||||
|
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function NotesPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: notes = [] } = useQuery({
|
||||||
|
queryKey: ['site-notes'],
|
||||||
|
queryFn: () => client.get('/api/notes/').then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: todos = [] } = useQuery({
|
||||||
|
queryKey: ['site-todos'],
|
||||||
|
queryFn: () => client.get('/api/notes/todos').then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Note mutations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addNote = useMutation({
|
||||||
|
mutationFn: ({ body, is_pinned }) => client.post('/api/notes/', { body, is_pinned }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveNote = useMutation({
|
||||||
|
mutationFn: ({ id, body }) => client.put(`/api/notes/${id}`, { body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pinNote = useMutation({
|
||||||
|
mutationFn: ({ id, is_pinned }) => client.put(`/api/notes/${id}`, { is_pinned }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteNote = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/notes/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Todo mutations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addTodo = useMutation({
|
||||||
|
mutationFn: ({ body, priority }) => client.post('/api/notes/todos', { body, priority }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleTodo = useMutation({
|
||||||
|
mutationFn: ({ id, is_done }) => client.put(`/api/notes/todos/${id}`, { is_done }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const editTodo = useMutation({
|
||||||
|
mutationFn: ({ id, body }) => client.put(`/api/notes/todos/${id}`, { body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/notes/todos/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Page header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '18px 28px 14px',
|
||||||
|
borderBottom: '1px solid #f0f0ef',
|
||||||
|
display: 'flex', alignItems: 'baseline', gap: 16, flexShrink: 0,
|
||||||
|
background: 'white',
|
||||||
|
}}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Σημειώσεις & Εργασίες</h1>
|
||||||
|
<span style={{ fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
{notes.length} σημειώσεις · {todos.filter(t => !t.is_done).length} εκκρεμείς εργασίες
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column body */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 0, flex: 1, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Left: Notes */}
|
||||||
|
<div style={{
|
||||||
|
borderRight: '1px solid #f0f0ef',
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
|
||||||
|
Σημειώσεις
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<NotesColumn
|
||||||
|
notes={notes}
|
||||||
|
onAdd={(body, is_pinned) => addNote.mutate({ body, is_pinned })}
|
||||||
|
onSave={(id, body) => saveNote.mutate({ id, body })}
|
||||||
|
onDelete={(id) => deleteNote.mutate(id)}
|
||||||
|
onTogglePin={(id, is_pinned) => pinNote.mutate({ id, is_pinned })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Todos */}
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
|
||||||
|
Εργασίες
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<TodosColumn
|
||||||
|
todos={todos}
|
||||||
|
onAdd={(body, priority) => addTodo.mutate({ body, priority })}
|
||||||
|
onToggle={(id, is_done) => toggleTodo.mutate({ id, is_done })}
|
||||||
|
onEdit={(id, body) => editTodo.mutate({ id, body })}
|
||||||
|
onDelete={(id) => deleteTodo.mutate(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
394
manager_dashboard/src/pages/OnlineOrdersPage.jsx
Normal file
394
manager_dashboard/src/pages/OnlineOrdersPage.jsx
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import {
|
||||||
|
getIncomingOrders,
|
||||||
|
getActiveOrders,
|
||||||
|
getOrderHistory,
|
||||||
|
acceptOnlineOrder,
|
||||||
|
rejectOnlineOrder,
|
||||||
|
updateOnlineOrderStatus,
|
||||||
|
} from '../api/client'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTotals(items = []) {
|
||||||
|
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
pending_acceptance: { label: 'Αναμονή', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
accepted: { label: 'Αποδεκτή', cls: 'bg-blue-100 text-blue-700' },
|
||||||
|
preparing: { label: 'Προετοιμασία', cls: 'bg-indigo-100 text-indigo-700' },
|
||||||
|
ready: { label: 'Έτοιμη', cls: 'bg-green-100 text-green-700' },
|
||||||
|
out_for_delivery: { label: 'Παράδοση', cls: 'bg-purple-100 text-purple-700' },
|
||||||
|
delivered: { label: 'Παραδόθηκε', cls: 'bg-gray-100 text-gray-500' },
|
||||||
|
rejected: { label: 'Απορρίφθηκε', cls: 'bg-red-100 text-red-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnlineStatusBadge({ status }) {
|
||||||
|
const { label, cls } = STATUS_LABELS[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' }
|
||||||
|
return (
|
||||||
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order Card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OrderCard({ order, onAction }) {
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false)
|
||||||
|
const [rejectReason, setRejectReason] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
|
// Refresh the "X min ago" label every 30 s without a full re-render of parent
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick(t => t + 1), 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const subtotal = calcTotals(order.items)
|
||||||
|
const isDelivery = order.online_order_type === 'delivery'
|
||||||
|
const st = order.online_status
|
||||||
|
|
||||||
|
async function act(fn) {
|
||||||
|
setBusy(true)
|
||||||
|
try { await fn() } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/60">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-gray-800 text-base">
|
||||||
|
{order.online_order_ref ?? `#${order.id}`}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
isDelivery ? 'bg-orange-100 text-orange-700' : 'bg-teal-100 text-teal-700'
|
||||||
|
}`}>
|
||||||
|
{isDelivery ? 'Delivery' : 'Dine-in'}
|
||||||
|
</span>
|
||||||
|
<OnlineStatusBadge status={st} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">{timeAgo(order.opened_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer info */}
|
||||||
|
<div className="px-4 py-3 space-y-0.5 border-b border-gray-100 text-sm text-gray-700">
|
||||||
|
{order.online_customer_name && (
|
||||||
|
<p className="font-medium">{order.online_customer_name}</p>
|
||||||
|
)}
|
||||||
|
{order.online_customer_phone && (
|
||||||
|
<p className="text-gray-500">{order.online_customer_phone}</p>
|
||||||
|
)}
|
||||||
|
{isDelivery && order.online_customer_address && (
|
||||||
|
<p className="text-gray-500">{order.online_customer_address}</p>
|
||||||
|
)}
|
||||||
|
{order.online_customer_notes && (
|
||||||
|
<p className="text-amber-700 bg-amber-50 rounded px-2 py-1 text-xs mt-1">
|
||||||
|
📝 {order.online_customer_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<div className="space-y-1 text-sm text-gray-700">
|
||||||
|
{order.items.map((it, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<span>{it.quantity} × {it.product?.name ?? `#${it.product_id}`}</span>
|
||||||
|
<span className="text-gray-500">€{(it.unit_price * it.quantity).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-100 flex justify-between font-semibold text-sm">
|
||||||
|
<span>Σύνολο</span>
|
||||||
|
<span>€{subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-4 py-3 flex flex-wrap gap-2">
|
||||||
|
{st === 'pending_acceptance' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('accept', order.id))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-primary text-sm px-4 min-h-0 h-9 bg-green-600 hover:bg-green-700 border-green-600"
|
||||||
|
>
|
||||||
|
Αποδοχή
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejectOpen(r => !r)}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-danger text-sm px-4 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Απόρριψη
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{st === 'accepted' && (
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('status', order.id, 'preparing'))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Προετοιμασία
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{st === 'preparing' && (
|
||||||
|
isDelivery ? (
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('status', order.id, 'out_for_delivery'))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Εξωτερική Παράδοση
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('status', order.id, 'ready'))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Έτοιμη
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{(st === 'ready' || st === 'out_for_delivery') && (
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('status', order.id, 'delivered'))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-primary text-sm px-4 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Παραδόθηκε
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reject inline form */}
|
||||||
|
{rejectOpen && (
|
||||||
|
<div className="px-4 pb-4 flex gap-2 items-start border-t border-gray-100 pt-3">
|
||||||
|
<textarea
|
||||||
|
className="input flex-1 resize-none text-sm"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Λόγος απόρριψης (προαιρετικό)"
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={e => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => act(() => onAction('reject', order.id, rejectReason))}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-danger text-sm px-3 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Επιβεβαίωση
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRejectOpen(false)}
|
||||||
|
className="btn btn-secondary text-sm px-3 min-h-0 h-9"
|
||||||
|
>
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OrderList({ orders, onAction, emptyText }) {
|
||||||
|
if (!orders.length) {
|
||||||
|
return <p className="text-sm text-gray-400 text-center py-12">{emptyText}</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orders.map(o => (
|
||||||
|
<OrderCard key={o.id} order={o} onAction={onAction} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'incoming', label: 'Εισερχόμενες' },
|
||||||
|
{ key: 'active', label: 'Ενεργές' },
|
||||||
|
{ key: 'history', label: 'Ιστορικό' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function OnlineOrdersPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState('incoming')
|
||||||
|
const [incoming, setIncoming] = useState([])
|
||||||
|
const [active, setActive] = useState([])
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [inc, act, hist] = await Promise.all([
|
||||||
|
getIncomingOrders(),
|
||||||
|
getActiveOrders(),
|
||||||
|
getOrderHistory(),
|
||||||
|
])
|
||||||
|
setIncoming(inc.data)
|
||||||
|
setActive(act.data)
|
||||||
|
setHistory(hist.data)
|
||||||
|
} catch {
|
||||||
|
setError('Σφάλμα φόρτωσης παραγγελιών.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initial fetch + 20 s polling
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll()
|
||||||
|
pollRef.current = setInterval(fetchAll, 20_000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [fetchAll])
|
||||||
|
|
||||||
|
// SSE listener — refetch on online_order_received or online_order_updated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return
|
||||||
|
const es = new EventSource(`/api/stream?token=${encodeURIComponent(token)}`)
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const { type } = JSON.parse(e.data)
|
||||||
|
if (type === 'online_order_received' || type === 'online_order_updated') {
|
||||||
|
fetchAll()
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed frames */ }
|
||||||
|
}
|
||||||
|
return () => es.close()
|
||||||
|
}, [token, fetchAll])
|
||||||
|
|
||||||
|
async function handleAction(action, orderId, extra) {
|
||||||
|
try {
|
||||||
|
if (action === 'accept') {
|
||||||
|
const { data: updated } = await acceptOnlineOrder(orderId)
|
||||||
|
setIncoming(prev => prev.filter(o => o.id !== orderId))
|
||||||
|
setActive(prev => [updated, ...prev])
|
||||||
|
} else if (action === 'reject') {
|
||||||
|
const { data: updated } = await rejectOnlineOrder(orderId, extra)
|
||||||
|
setIncoming(prev => prev.filter(o => o.id !== orderId))
|
||||||
|
setHistory(prev => [updated, ...prev])
|
||||||
|
} else if (action === 'status') {
|
||||||
|
const { data: updated } = await updateOnlineOrderStatus(orderId, extra)
|
||||||
|
const isDone = updated.online_status === 'delivered' || updated.online_status === 'rejected'
|
||||||
|
if (isDone) {
|
||||||
|
setActive(prev => prev.filter(o => o.id !== orderId))
|
||||||
|
setHistory(prev => [updated, ...prev])
|
||||||
|
} else {
|
||||||
|
setActive(prev => prev.map(o => o.id === orderId ? updated : o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Re-fetch to sync — avoids stale state after a failed optimistic update
|
||||||
|
fetchAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = incoming.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="px-6 py-5 border-b border-gray-100 shrink-0 flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Online Παραγγελίες</h1>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={fetchAll}
|
||||||
|
disabled={loading}
|
||||||
|
className="ml-auto btn btn-secondary text-sm px-3 min-h-0 h-8"
|
||||||
|
>
|
||||||
|
{loading ? '…' : '↻ Ανανέωση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200 shrink-0 bg-white px-4">
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const count = tab.key === 'incoming' ? incoming.length
|
||||||
|
: tab.key === 'active' ? active.length
|
||||||
|
: history.length
|
||||||
|
const isActive = activeTab === tab.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
|
||||||
|
isActive
|
||||||
|
? 'border-primary-600 text-primary-700 bg-primary-50/50'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
|
||||||
|
tab.key === 'incoming'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'incoming' && (
|
||||||
|
<OrderList
|
||||||
|
orders={incoming}
|
||||||
|
onAction={handleAction}
|
||||||
|
emptyText="Δεν υπάρχουν εισερχόμενες παραγγελίες."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'active' && (
|
||||||
|
<OrderList
|
||||||
|
orders={active}
|
||||||
|
onAction={handleAction}
|
||||||
|
emptyText="Δεν υπάρχουν ενεργές παραγγελίες."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<OrderList
|
||||||
|
orders={history}
|
||||||
|
onAction={handleAction}
|
||||||
|
emptyText="Δεν υπάρχει ιστορικό παραγγελιών."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -157,6 +157,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||||
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
|
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('')
|
||||||
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
||||||
|
|
||||||
const { data: order, isLoading } = useQuery({
|
const { data: order, isLoading } = useQuery({
|
||||||
queryKey: ['order', orderId],
|
queryKey: ['order', orderId],
|
||||||
@@ -220,12 +222,31 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: customersData = [] } = useQuery({
|
||||||
|
queryKey: ['customers', customerSearch],
|
||||||
|
queryFn: () => client.get('/api/customers/', { params: customerSearch ? { search: customerSearch } : {} }).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: showCustomerDropdown,
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignCustomer = useMutation({
|
||||||
|
mutationFn: (customer_id) => client.put(`/api/orders/${orderId}/customer`, { customer_id }),
|
||||||
|
onSuccess: (_, customer_id) => { toast.success(customer_id ? 'Πελάτης ανατέθηκε' : 'Πελάτης αφαιρέθηκε'); invalidate(); setShowCustomerDropdown(false); setCustomerSearch('') },
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
const payItems = useMutation({
|
const payItems = useMutation({
|
||||||
mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
|
mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
|
||||||
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
||||||
onError: () => toast.error('Σφάλμα πληρωμής'),
|
onError: () => toast.error('Σφάλμα πληρωμής'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tabItem = useMutation({
|
||||||
|
mutationFn: (itemId) => client.post(`/api/orders/${orderId}/items/${itemId}/tab`),
|
||||||
|
onSuccess: () => { toast.success('Αντικείμενο μεταφέρθηκε στην καρτέλα'); invalidate() },
|
||||||
|
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
function requestPay(item_ids) {
|
function requestPay(item_ids) {
|
||||||
setPayPending(item_ids)
|
setPayPending(item_ids)
|
||||||
}
|
}
|
||||||
@@ -267,7 +288,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
|
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
|
||||||
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +298,73 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Customer assignment */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">Πελάτης</h2>
|
||||||
|
{order.customer_id && isOpen && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => assignCustomer.mutate(null)}
|
||||||
|
className="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
Αφαίρεση
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.customer_id ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', background: '#eff6ff', borderRadius: 8, border: '1px solid #bfdbfe' }}>
|
||||||
|
<span style={{ fontSize: 16 }}>👤</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#1d4ed8' }}>{order.customer_name || `Πελάτης #${order.customer_id}`}</div>
|
||||||
|
{order.customer_phone && <div style={{ fontSize: 11.5, color: '#3b82f6' }}>{order.customer_phone}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isOpen && !readOnly ? (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{!showCustomerDropdown ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCustomerDropdown(true)}
|
||||||
|
style={{ fontSize: 13, color: '#6b7280', border: '1px dashed #d1d5db', borderRadius: 8, padding: '7px 14px', cursor: 'pointer', background: 'white', width: '100%', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
+ Ανάθεση πελάτη
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={e => setCustomerSearch(e.target.value)}
|
||||||
|
placeholder="Αναζήτηση πελάτη…"
|
||||||
|
style={{ width: '100%', padding: '7px 11px', border: '1.5px solid #3b82f6', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.10)', zIndex: 20, maxHeight: 220, overflowY: 'auto', marginTop: 4 }}>
|
||||||
|
{customersData.length === 0 && (
|
||||||
|
<div style={{ padding: '10px 14px', fontSize: 13, color: '#9ca3af' }}>Δεν βρέθηκαν πελάτες</div>
|
||||||
|
)}
|
||||||
|
{customersData.map(c => (
|
||||||
|
<button key={c.id} onClick={() => assignCustomer.mutate(c.id)}
|
||||||
|
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '9px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, borderBottom: '1px solid #f3f4f6' }}
|
||||||
|
className="hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, color: '#111315' }}>{c.name}</span>
|
||||||
|
{c.nickname && <span style={{ color: '#9ca3af', marginLeft: 5 }}>«{c.nickname}»</span>}
|
||||||
|
{c.phone && <span style={{ color: '#6b7280', marginLeft: 8, fontSize: 12 }}>{c.phone}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => { setShowCustomerDropdown(false); setCustomerSearch('') }}
|
||||||
|
style={{ display: 'block', width: '100%', padding: '8px 14px', fontSize: 12, color: '#9ca3af', border: 'none', background: 'none', cursor: 'pointer', borderTop: '1px solid #f0f0ef' }}>
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 13, color: '#d1d5db' }}>Χωρίς πελάτη</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 border-b border-gray-200">
|
<div className="flex gap-1 border-b border-gray-200">
|
||||||
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||||
@@ -366,6 +454,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
>
|
>
|
||||||
Πληρωμή
|
Πληρωμή
|
||||||
</button>
|
</button>
|
||||||
|
{order.customer_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => tabItem.mutate(item.id)}
|
||||||
|
style={{ fontSize: 12, padding: '0 8px', height: 32, borderRadius: 6, border: '1px solid #bfdbfe', background: '#eff6ff', color: '#1d4ed8', cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
📋 Καρτέλα
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||||
@@ -374,6 +470,11 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{item.status === 'tabbed' && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>
|
||||||
|
📋 Καρτέλα
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
375
manager_dashboard/src/pages/SchedulePage.jsx
Normal file
375
manager_dashboard/src/pages/SchedulePage.jsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Δευ', 'Τρί', 'Τετ', 'Πέμ', 'Παρ', 'Σάβ', 'Κυρ']
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekMonday(d) {
|
||||||
|
const day = new Date(d)
|
||||||
|
const dow = day.getDay()
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow
|
||||||
|
day.setDate(day.getDate() + diff)
|
||||||
|
day.setHours(0, 0, 0, 0)
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d, n) {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setDate(r.getDate() + n)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateStr(d) {
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtShortDate(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso + 'T00:00:00')
|
||||||
|
return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(t) {
|
||||||
|
if (!t) return '—'
|
||||||
|
return t.slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(h) {
|
||||||
|
if (!h) return ''
|
||||||
|
const hrs = Math.floor(h)
|
||||||
|
const mins = Math.round((h - hrs) * 60)
|
||||||
|
if (hrs === 0) return `${mins}λ`
|
||||||
|
if (mins === 0) return `${hrs}ω`
|
||||||
|
return `${hrs}ω${mins}λ`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shift slot in cell ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ShiftSlot({ shift, actual, onDelete }) {
|
||||||
|
const hasActual = actual && actual.length > 0
|
||||||
|
const isActive = actual?.some(a => a.is_active)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 7, overflow: 'hidden',
|
||||||
|
border: `1.5px solid ${hasActual ? '#86efac' : '#bfdbfe'}`,
|
||||||
|
background: hasActual ? '#f0fdf4' : '#eff6ff',
|
||||||
|
fontSize: 11.5,
|
||||||
|
}}>
|
||||||
|
{/* Scheduled row */}
|
||||||
|
<div style={{ padding: '5px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||||
|
<span style={{ fontWeight: 700, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
|
||||||
|
{fmtTime(shift.start_time)}–{fmtTime(shift.end_time)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{shift.estimated_pay != null && (
|
||||||
|
<span style={{ color: '#6b7280', fontSize: 10.5 }}>{fmt(shift.estimated_pay)}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(shift.id)}
|
||||||
|
style={{ fontSize: 10, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actual row */}
|
||||||
|
{hasActual ? (
|
||||||
|
actual.map(a => (
|
||||||
|
<div key={a.id} style={{
|
||||||
|
padding: '3px 8px', borderTop: '1px solid #bbf7d0',
|
||||||
|
fontSize: 10.5, color: '#15803d', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
✓ {a.started_at ? new Date(a.started_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||||
|
{a.ended_at
|
||||||
|
? ` – ${new Date(a.ended_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
: isActive ? ' (ενεργή)' : ''}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '3px 8px', borderTop: '1px solid #bfdbfe', fontSize: 10.5, color: '#94a3b8' }}>
|
||||||
|
Δεν ξεκίνησε
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add shift modal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AddShiftModal({ date, waiters, onClose, onAdd, isPending }) {
|
||||||
|
const [userId, setUserId] = useState(waiters[0]?.id ?? '')
|
||||||
|
const [start, setStart] = useState('09:00')
|
||||||
|
const [end, setEnd] = useState('17:00')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 7, fontSize: 13, outline: 'none', fontFamily: 'inherit',
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAdd = userId && start && end
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 14, width: '100%', maxWidth: 420, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||||
|
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700 }}>Νέα Βάρδια</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{fmtShortDate(date)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σερβιτόρος</label>
|
||||||
|
<select style={inputStyle} value={userId} onChange={e => setUserId(e.target.value)}>
|
||||||
|
{waiters.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Έναρξη</label>
|
||||||
|
<input type="time" style={inputStyle} value={start} onChange={e => setStart(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Λήξη</label>
|
||||||
|
<input type="time" style={inputStyle} value={end} onChange={e => setEnd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span></label>
|
||||||
|
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μόνο βράδυ" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 20px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '7px 16px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAdd({ user_id: Number(userId), scheduled_date: date, start_time: start, end_time: end, notes: notes || null })}
|
||||||
|
disabled={!canAdd || isPending}
|
||||||
|
style={{ padding: '7px 18px', border: 'none', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: canAdd ? 'pointer' : 'default', background: canAdd ? '#111827' : '#e5e7eb', color: canAdd ? 'white' : '#9ca3af' }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Προσθήκη'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SchedulePage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [weekAnchor, setWeekAnchor] = useState(() => weekMonday(new Date()))
|
||||||
|
const [addModal, setAddModal] = useState(null) // date string | null
|
||||||
|
|
||||||
|
const weekStartStr = toLocalDateStr(weekAnchor)
|
||||||
|
const weekEndStr = toLocalDateStr(addDays(weekAnchor, 6))
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['schedule-week', weekStartStr],
|
||||||
|
queryFn: () => client.get('/api/schedule/week', { params: { week_start: weekStartStr } }).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addShift = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/schedule/', body),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); setAddModal(null); toast.success('Βάρδια προστέθηκε') },
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteShift = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/schedule/${id}`),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); toast.success('Βάρδια διαγράφηκε') },
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const waiters = data?.waiters ?? []
|
||||||
|
const scheduled = data?.scheduled ?? []
|
||||||
|
const totalPay = data?.total_estimated_pay ?? 0
|
||||||
|
|
||||||
|
// Build lookup: waiter_id → name
|
||||||
|
const waiterNames = Object.fromEntries(waiters.map(w => [w.id, w.name]))
|
||||||
|
|
||||||
|
// Build 7 day columns
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = addDays(weekAnchor, i)
|
||||||
|
return { date: d, dateStr: toLocalDateStr(d), label: DAY_LABELS[i] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build per-waiter per-day grid
|
||||||
|
// { waiter_id: { dateStr: { scheduled: [...], actual: [...] } } }
|
||||||
|
const grid = {}
|
||||||
|
|
||||||
|
for (const s of scheduled) {
|
||||||
|
if (!grid[s.user_id]) grid[s.user_id] = {}
|
||||||
|
const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date))
|
||||||
|
if (!grid[s.user_id][ds]) grid[s.user_id][ds] = { scheduled: [], actual: [] }
|
||||||
|
grid[s.user_id][ds].scheduled.push(s)
|
||||||
|
grid[s.user_id][ds].actual.push(...(s.actual_shifts || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique waiter ids that appear in this week's schedule
|
||||||
|
const activeWaiterIds = [...new Set(scheduled.map(s => s.user_id))]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 24px 12px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πρόγραμμα</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
{fmtShortDate(weekStartStr)} – {fmtShortDate(weekEndStr)}
|
||||||
|
{totalPay > 0 && <span style={{ marginLeft: 10, color: '#6b7280' }}>Εκτιμώμενο κόστος εβδομάδας: <strong style={{ color: '#111315' }}>{fmt(totalPay)}</strong></span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setWeekAnchor(w => addDays(w, -7))}
|
||||||
|
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>← Προηγ.</button>
|
||||||
|
<button onClick={() => setWeekAnchor(weekMonday(new Date()))}
|
||||||
|
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Αυτή η εβδομάδα</button>
|
||||||
|
<button onClick={() => setWeekAnchor(w => addDays(w, 7))}
|
||||||
|
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Επόμ. →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div style={{ flex: 1, overflowX: 'auto', overflowY: 'auto', padding: '12px 24px 16px' }}>
|
||||||
|
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && (
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 700 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: 12, fontWeight: 700, color: '#9ca3af', width: 140 }}>Σερβιτόρος</th>
|
||||||
|
{days.map(d => {
|
||||||
|
const isToday = toLocalDateStr(new Date()) === d.dateStr
|
||||||
|
return (
|
||||||
|
<th key={d.dateStr} style={{
|
||||||
|
padding: '8px 4px', textAlign: 'center', fontSize: 12,
|
||||||
|
fontWeight: isToday ? 800 : 600,
|
||||||
|
color: isToday ? '#3b82f6' : '#374151',
|
||||||
|
minWidth: 110,
|
||||||
|
}}>
|
||||||
|
<div>{d.label}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: isToday ? '#3b82f6' : '#9ca3af' }}>
|
||||||
|
{fmtShortDate(d.dateStr)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Rows for scheduled waiters */}
|
||||||
|
{activeWaiterIds.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '40px 0' }}>
|
||||||
|
Δεν υπάρχουν προγραμματισμένες βάρδιες αυτή την εβδομάδα.<br />
|
||||||
|
Κάντε κλικ σε ένα κελί για να προσθέσετε.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{activeWaiterIds.map(uid => {
|
||||||
|
const w = waiters.find(w => w.id === uid) || { id: uid, name: waiterNames[uid] || `#${uid}`, hourly_rate: null }
|
||||||
|
return (
|
||||||
|
<tr key={uid} style={{ borderTop: '1px solid #f3f4f6' }}>
|
||||||
|
<td style={{ padding: '8px 12px', verticalAlign: 'top' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315' }}>{w.name}</div>
|
||||||
|
{w.hourly_rate && <div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(w.hourly_rate)}/ώρα</div>}
|
||||||
|
</td>
|
||||||
|
{days.map(d => {
|
||||||
|
const cell = grid[uid]?.[d.dateStr]
|
||||||
|
return (
|
||||||
|
<td key={d.dateStr} style={{ padding: '4px', verticalAlign: 'top', position: 'relative' }}
|
||||||
|
onClick={() => { if (!cell?.scheduled?.length) setAddModal({ date: d.dateStr, userId: uid }) }}
|
||||||
|
>
|
||||||
|
<div style={{ minHeight: 48, cursor: cell?.scheduled?.length ? 'default' : 'pointer' }}>
|
||||||
|
{cell?.scheduled?.map(s => (
|
||||||
|
<ShiftSlot
|
||||||
|
key={s.id} shift={s}
|
||||||
|
actual={cell.actual}
|
||||||
|
onDelete={id => { if (window.confirm('Διαγραφή βάρδιας;')) deleteShift.mutate(id) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!cell?.scheduled?.length && (
|
||||||
|
<div style={{
|
||||||
|
height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#e5e7eb', fontSize: 18, borderRadius: 7,
|
||||||
|
border: '1.5px dashed transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = '#bfdbfe'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add row for other waiters not yet scheduled */}
|
||||||
|
<tr style={{ borderTop: '1px solid #f3f4f6' }}>
|
||||||
|
<td colSpan={8} style={{ padding: '8px 12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{waiters.filter(w => !activeWaiterIds.includes(w.id)).map(w => (
|
||||||
|
<button key={w.id}
|
||||||
|
onClick={() => setAddModal({ date: weekStartStr, userId: w.id })}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: '1px dashed #d1d5db', borderRadius: 20, background: 'white', color: '#6b7280',
|
||||||
|
}}>
|
||||||
|
+ {w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{waiters.length === 0 && (
|
||||||
|
<span style={{ fontSize: 12, color: '#d1d5db' }}>Δεν υπάρχουν ενεργοί σερβιτόροι.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ padding: '8px 24px', borderTop: '1px solid #f0f0ef', background: '#fafafa', display: 'flex', gap: 16, flexShrink: 0 }}>
|
||||||
|
{[
|
||||||
|
{ color: '#bfdbfe', bg: '#eff6ff', label: 'Προγραμματισμένη (δεν ξεκίνησε)' },
|
||||||
|
{ color: '#86efac', bg: '#f0fdf4', label: 'Προγραμματισμένη + Πραγματική' },
|
||||||
|
].map(l => (
|
||||||
|
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: '#6b7280' }}>
|
||||||
|
<div style={{ width: 14, height: 10, borderRadius: 3, background: l.bg, border: `1.5px solid ${l.color}` }} />
|
||||||
|
{l.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addModal && (
|
||||||
|
<AddShiftModal
|
||||||
|
date={addModal.date}
|
||||||
|
waiters={addModal.userId
|
||||||
|
? waiters.filter(w => w.id === addModal.userId).concat(waiters.filter(w => w.id !== addModal.userId))
|
||||||
|
: waiters}
|
||||||
|
onClose={() => setAddModal(null)}
|
||||||
|
isPending={addShift.isPending}
|
||||||
|
onAdd={(body) => addShift.mutate(body)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import ColoursTab from './tabs/ColoursTab'
|
|||||||
import OperationTab from './tabs/OperationTab'
|
import OperationTab from './tabs/OperationTab'
|
||||||
import PrintFontsTab from './tabs/PrintFontsTab'
|
import PrintFontsTab from './tabs/PrintFontsTab'
|
||||||
import SecurityTab from './tabs/SecurityTab'
|
import SecurityTab from './tabs/SecurityTab'
|
||||||
|
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||||
|
import Phase2FeaturesTab from './tabs/Phase2FeaturesTab'
|
||||||
import { TabGroup } from '../../ui/Tabs'
|
import { TabGroup } from '../../ui/Tabs'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -12,6 +14,8 @@ const TABS = [
|
|||||||
{ id: 'operation', label: 'Λειτουργία' },
|
{ id: 'operation', label: 'Λειτουργία' },
|
||||||
{ id: 'colours', label: 'Εμφάνιση' },
|
{ id: 'colours', label: 'Εμφάνιση' },
|
||||||
{ id: 'print-fonts', label: 'Εκτύπωση' },
|
{ id: 'print-fonts', label: 'Εκτύπωση' },
|
||||||
|
{ id: 'features', label: 'Λειτουργίες' },
|
||||||
|
{ id: 'development', label: 'Developer' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -26,6 +30,8 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'operation' && <OperationTab />}
|
{activeTab === 'operation' && <OperationTab />}
|
||||||
{activeTab === 'colours' && <ColoursTab />}
|
{activeTab === 'colours' && <ColoursTab />}
|
||||||
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||||
|
{activeTab === 'features' && <Phase2FeaturesTab />}
|
||||||
|
{activeTab === 'development' && <DevelopmentTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
135
manager_dashboard/src/pages/Settings/tabs/Phase2FeaturesTab.jsx
Normal file
135
manager_dashboard/src/pages/Settings/tabs/Phase2FeaturesTab.jsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { PHASE2_FEATURE_DEFS, isFeatureEnabled, setFeatureEnabled, usePhase2Features } from '../../../hooks/usePhase2Features'
|
||||||
|
|
||||||
|
const SECRET = '4034'
|
||||||
|
|
||||||
|
function PinGate({ onUnlock }) {
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (pin === SECRET) {
|
||||||
|
onUnlock()
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
setPin('')
|
||||||
|
setTimeout(() => setError(false), 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-5 py-16 max-w-xs mx-auto">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 text-2xl">🔒</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-semibold text-slate-800">Περιορισμένη πρόσβαση</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Εισάγετε τον κωδικό για να συνεχίσετε</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-3 w-full">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pin}
|
||||||
|
onChange={e => setPin(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||||
|
autoFocus
|
||||||
|
maxLength={8}
|
||||||
|
placeholder="••••"
|
||||||
|
className={`w-full text-center text-xl tracking-[0.4em] border rounded-xl px-4 py-3 outline-none transition-colors ${
|
||||||
|
error ? 'border-red-400 bg-red-50 text-red-600' : 'border-slate-200 focus:border-sky-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-500 font-medium">Λάθος κωδικός</p>}
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
className="w-full rounded-xl bg-slate-900 text-white font-semibold py-2.5 text-sm hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Είσοδος
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
style={{
|
||||||
|
width: 44, height: 24, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
|
background: checked ? '#16a34a' : '#d1d5db',
|
||||||
|
position: 'relative', transition: 'background 150ms', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 3, left: checked ? 23 : 3,
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Phase2FeaturesTab() {
|
||||||
|
const [unlocked, setUnlocked] = useState(false)
|
||||||
|
const features = usePhase2Features()
|
||||||
|
|
||||||
|
if (!unlocked) return <PinGate onUnlock={() => setUnlocked(true)} />
|
||||||
|
|
||||||
|
const allOn = features.every(f => f.enabled)
|
||||||
|
const allOff = features.every(f => !f.enabled)
|
||||||
|
|
||||||
|
function toggleAll(value) {
|
||||||
|
PHASE2_FEATURE_DEFS.forEach(f => setFeatureEnabled(f.id, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-800">
|
||||||
|
Αυτές οι επιλογές ελέγχουν ποιες σελίδες Phase 2 εμφανίζονται στο πλαϊνό μενού.
|
||||||
|
Απενεργοποίηση <strong>δεν σβήνει δεδομένα</strong> — απλώς κρύβει τον σύνδεσμο.
|
||||||
|
Χρήσιμο για να κρατάτε σελίδες σε εξέλιξη κρυφές από πελάτες.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
{/* Master toggle row */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-3 bg-gray-50/60">
|
||||||
|
<span className="text-sm font-semibold text-gray-600">Όλες οι λειτουργίες</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAll(true)}
|
||||||
|
disabled={allOn}
|
||||||
|
className="text-xs font-semibold px-3 py-1 rounded-full border border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-40 disabled:cursor-default"
|
||||||
|
>
|
||||||
|
Ενεργοποίηση όλων
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAll(false)}
|
||||||
|
disabled={allOff}
|
||||||
|
className="text-xs font-semibold px-3 py-1 rounded-full border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-40 disabled:cursor-default"
|
||||||
|
>
|
||||||
|
Απενεργοποίηση όλων
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{features.map(f => (
|
||||||
|
<div key={f.id} className="flex items-center justify-between gap-4 px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">{f.label}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5 font-mono">{f.route}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={f.enabled}
|
||||||
|
onChange={val => setFeatureEnabled(f.id, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Οι ρυθμίσεις αποθηκεύονται τοπικά στον browser. Ισχύουν αμέσως χωρίς ανανέωση.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
@@ -30,6 +30,13 @@ const DIVIDER_OPTIONS = [
|
|||||||
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DOT_STYLE_OPTIONS = [
|
||||||
|
{ value: 'dot_space', label: 'Τελεία + κενό ( . )', preview: '. . . . .' },
|
||||||
|
{ value: 'dot', label: 'Τελείες ( . )', preview: '.........' },
|
||||||
|
{ value: 'dash_space', label: 'Παύλα + κενό ( - )', preview: '- - - - -' },
|
||||||
|
{ value: 'underscore', label: 'Κάτω παύλα ( _ )', preview: '_________' },
|
||||||
|
]
|
||||||
|
|
||||||
const FONT_DEFAULTS = {
|
const FONT_DEFAULTS = {
|
||||||
'print.font_order_number': '48:1:0',
|
'print.font_order_number': '48:1:0',
|
||||||
'print.font_meta': '0:0:0',
|
'print.font_meta': '0:0:0',
|
||||||
@@ -41,7 +48,10 @@ const FONT_DEFAULTS = {
|
|||||||
'print.font_item_note': '0:0:0',
|
'print.font_item_note': '0:0:0',
|
||||||
'print.font_order_note': '0:1:0',
|
'print.font_order_note': '0:1:0',
|
||||||
'print.divider_style': 'dash',
|
'print.divider_style': 'dash',
|
||||||
|
'print.dot_style': 'dot_space:0:0',
|
||||||
'print.ticket_mode': 'detailed',
|
'print.ticket_mode': 'detailed',
|
||||||
|
'print.beep_on_ticket': 'true',
|
||||||
|
'print.beep_pattern': 'double',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preview ────────────────────────────────────────────────────────────────
|
// ── Preview ────────────────────────────────────────────────────────────────
|
||||||
@@ -229,6 +239,100 @@ function DividerRow({ value, onChange, isPending }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dot style row ─────────────────────────────────────────────────────────
|
||||||
|
function decodeDotStyle(val) {
|
||||||
|
if (!val) return { style: 'dot_space', size: '0', bold: false }
|
||||||
|
const [style, size, bold] = val.split(':')
|
||||||
|
return { style: style ?? 'dot_space', size: size ?? '0', bold: bold === '1' }
|
||||||
|
}
|
||||||
|
function encodeDotStyle(style, size, bold) {
|
||||||
|
return `${style}:${size}:${bold ? '1' : '0'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function DotStyleRow({ value, onChange, isPending }) {
|
||||||
|
const { style, size, bold } = decodeDotStyle(value)
|
||||||
|
const selected = DOT_STYLE_OPTIONS.find(o => o.value === style) ?? DOT_STYLE_OPTIONS[0]
|
||||||
|
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||||
|
|
||||||
|
function handleStyle(e) { onChange('print.dot_style', encodeDotStyle(e.target.value, size, bold)) }
|
||||||
|
function handleSize(e) { onChange('print.dot_style', encodeDotStyle(style, e.target.value, bold)) }
|
||||||
|
function handleBold() { onChange('print.dot_style', encodeDotStyle(style, size, !bold)) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '10px 20px 10px 36px',
|
||||||
|
borderBottom: '1px solid #f4f4f2',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}>└</span>
|
||||||
|
|
||||||
|
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||||
|
Στυλ Dots
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>Χαρακτήρας · μέγεθος · έντονο για τη γραμμή dot-leader</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style dropdown */}
|
||||||
|
<select
|
||||||
|
value={style}
|
||||||
|
onChange={handleStyle}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DOT_STYLE_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<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: 'monospace',
|
||||||
|
fontSize: s.fontSize,
|
||||||
|
fontWeight: bold ? 800 : 400,
|
||||||
|
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'block',
|
||||||
|
}}>
|
||||||
|
Esp {selected.preview} x2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ticket mode section ────────────────────────────────────────────────────
|
// ── Ticket mode section ────────────────────────────────────────────────────
|
||||||
function TicketModeSection({ value, onChange, isPending, printers }) {
|
function TicketModeSection({ value, onChange, isPending, printers }) {
|
||||||
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
||||||
@@ -347,10 +451,193 @@ function TicketModeSection({ value, onChange, isPending, printers }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Beep section ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BEEP_PRESETS = [
|
||||||
|
{ id: 'off', label: 'Χωρίς Ήχο', desc: 'Χωρίς beep κατά την εκτύπωση' },
|
||||||
|
{ id: 'single', label: 'Μονό', desc: '1× παλμός 200ms' },
|
||||||
|
{ id: 'double', label: 'Διπλό', desc: '2× σύντομοι παλμοί (προτεινόμενο)' },
|
||||||
|
{ id: 'triple', label: 'Τριπλό', desc: '3× σύντομοι παλμοί — πιο εντονο' },
|
||||||
|
{ id: 'long', label: 'Μακρύ', desc: '1× παλμός 500ms' },
|
||||||
|
{ id: 'custom', label: 'Προσαρμοσμένο', desc: 'Ορίστε τη διάρκεια χειροκίνητα' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Parse beep settings → { preset, n1, n2, n3 }
|
||||||
|
function decodeBeep(patternVal, enabledVal) {
|
||||||
|
if (enabledVal === 'false') return { preset: 'off', n1: 2, n2: 2, n3: 1 }
|
||||||
|
if (!patternVal || patternVal === 'double') return { preset: 'double', n1: 1, n2: 1, n3: 2 }
|
||||||
|
if (patternVal.startsWith('custom:')) {
|
||||||
|
const [, n1, n2, n3] = patternVal.split(':')
|
||||||
|
return { preset: 'custom', n1: parseInt(n1) || 2, n2: parseInt(n2) || 2, n3: parseInt(n3) || 1 }
|
||||||
|
}
|
||||||
|
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
|
||||||
|
const [n1, n2, n3] = presetMap[patternVal] ?? [1,1,2]
|
||||||
|
return { preset: patternVal, n1, n2, n3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }) {
|
||||||
|
const { preset: initPreset, n1: iN1, n2: iN2, n3: iN3 } = decodeBeep(beepPattern, beepEnabled)
|
||||||
|
const [preset, setPreset] = useState(initPreset)
|
||||||
|
const [n1, setN1] = useState(iN1)
|
||||||
|
const [n2, setN2] = useState(iN2)
|
||||||
|
const [n3, setN3] = useState(iN3)
|
||||||
|
const [testPrinter, setTestPrinter] = useState(null)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (printers.length > 0 && !testPrinter) {
|
||||||
|
setTestPrinter((printers.find(p => p.is_active) ?? printers[0]).id)
|
||||||
|
}
|
||||||
|
}, [printers])
|
||||||
|
|
||||||
|
// Sync local state if parent settings change (e.g. after save)
|
||||||
|
useEffect(() => {
|
||||||
|
const decoded = decodeBeep(beepPattern, beepEnabled)
|
||||||
|
setPreset(decoded.preset)
|
||||||
|
setN1(decoded.n1); setN2(decoded.n2); setN3(decoded.n3)
|
||||||
|
}, [beepPattern, beepEnabled])
|
||||||
|
|
||||||
|
function handlePresetChange(id) {
|
||||||
|
setPreset(id)
|
||||||
|
if (id === 'off') {
|
||||||
|
onChange('print.beep_on_ticket', 'false')
|
||||||
|
} else if (id === 'custom') {
|
||||||
|
onChange('print.beep_on_ticket', 'true')
|
||||||
|
onChange('print.beep_pattern', `custom:${n1}:${n2}:${n3}`)
|
||||||
|
} else {
|
||||||
|
onChange('print.beep_on_ticket', 'true')
|
||||||
|
onChange('print.beep_pattern', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomChange(field, raw) {
|
||||||
|
const v = Math.max(1, Math.min(30, parseInt(raw) || 1))
|
||||||
|
const next = { n1, n2, n3, [field]: v }
|
||||||
|
if (field === 'n1') setN1(v)
|
||||||
|
if (field === 'n2') setN2(v)
|
||||||
|
if (field === 'n3') setN3(v)
|
||||||
|
onChange('print.beep_pattern', `custom:${next.n1}:${next.n2}:${next.n3}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute beep bytes from current state for test
|
||||||
|
function getBeepBytes() {
|
||||||
|
if (preset === 'custom') return { n1, n2, n3 }
|
||||||
|
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
|
||||||
|
const [bn1, bn2, bn3] = presetMap[preset] ?? [1,1,2]
|
||||||
|
return { n1: bn1, n2: bn2, n3: bn3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestBeep() {
|
||||||
|
if (!testPrinter || preset === 'off') return
|
||||||
|
const { n1: b1, n2: b2, n3: b3 } = getBeepBytes()
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await client.post(`/api/system/printers/test-beep?printer_id=${testPrinter}&n1=${b1}&n2=${b2}&n3=${b3}`)
|
||||||
|
if (res.data.success) toast.success('Beep στάλθηκε!')
|
||||||
|
else toast.error(`Σφάλμα: ${res.data.error}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Σφάλμα επικοινωνίας')
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<h2 className="font-semibold text-gray-700">Ήχος Εκτυπωτή (Beep)</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Ο εκτυπωτής χτυπά για να ειδοποιεί την κουζίνα σε κάθε νέα παραγγελία.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset grid */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', flexWrap: 'wrap' }}>
|
||||||
|
{BEEP_PRESETS.map(opt => {
|
||||||
|
const active = preset === opt.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => handlePresetChange(opt.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 140px', textAlign: 'left', padding: '12px 14px',
|
||||||
|
borderRadius: 10, cursor: isPending ? 'default' : 'pointer',
|
||||||
|
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
|
||||||
|
background: active ? '#eff3ff' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 3 }}>
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6b7280', lineHeight: 1.4 }}>{opt.desc}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom fields */}
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div style={{ padding: '14px 20px', display: 'flex', gap: 20, alignItems: 'flex-end', flexWrap: 'wrap', background: '#fafafa' }}>
|
||||||
|
{[
|
||||||
|
{ field: 'n1', label: 'Διάρκεια ON (×100ms)', val: n1, hint: 'π.χ. 3 = 300ms' },
|
||||||
|
{ field: 'n2', label: 'Διάρκεια OFF (×100ms)', val: n2, hint: 'Διάλλειμα μεταξύ παλμών' },
|
||||||
|
{ field: 'n3', label: 'Επαναλήψεις', val: n3, hint: 'Πόσες φορές' },
|
||||||
|
].map(({ field, label, val, hint }) => (
|
||||||
|
<div key={field} style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 140px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>{label}</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={30} value={val}
|
||||||
|
onChange={e => handleCustomChange(field, e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{ ...inputStyle, width: '100%' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 10, color: '#9ca3af' }}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test beep */}
|
||||||
|
<div style={{ padding: '14px 20px', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>Δοκιμαστικό Beep:</span>
|
||||||
|
{printers.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={testPrinter ?? ''}
|
||||||
|
onChange={e => setTestPrinter(Number(e.target.value))}
|
||||||
|
disabled={testing}
|
||||||
|
style={{ ...inputStyle, width: 200 }}
|
||||||
|
>
|
||||||
|
{printers.map(p => <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleTestBeep}
|
||||||
|
disabled={testing || preset === 'off' || !testPrinter}
|
||||||
|
style={{
|
||||||
|
...btnSecondary,
|
||||||
|
opacity: (testing || preset === 'off') ? 0.5 : 1,
|
||||||
|
cursor: (testing || preset === 'off') ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testing ? 'Αποστολή…' : '🔔 Τεστ Beep'}
|
||||||
|
</button>
|
||||||
|
{preset === 'off' && (
|
||||||
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>Ο ήχος είναι απενεργοποιημένος</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: '#ef4444' }}>Δεν υπάρχουν εκτυπωτές</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Printers section ───────────────────────────────────────────────────────
|
// ── Printers section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
|
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true }
|
||||||
|
|
||||||
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||||
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
@@ -382,6 +669,11 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
|||||||
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΧΑΡΑΚΤ./ΓΡΑΜΜΗ</label>
|
||||||
|
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
|
||||||
|
type="number" min={20} max={80} style={inputStyle} />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||||
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||||
style={btnPrimary}>Αποθήκευση</button>
|
style={btnPrimary}>Αποθήκευση</button>
|
||||||
@@ -446,6 +738,7 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
|||||||
{printer.ip_address}:{printer.port}
|
{printer.ip_address}:{printer.port}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.line_width ?? 48} χαρ.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -638,7 +931,16 @@ export default function PrintFontsTab() {
|
|||||||
printers={printers}
|
printers={printers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 3. Font sizes — grouped */}
|
{/* 3. Beep settings */}
|
||||||
|
<BeepSection
|
||||||
|
beepEnabled={val('print.beep_on_ticket')}
|
||||||
|
beepPattern={val('print.beep_pattern')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
printers={printers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. Font sizes */}
|
||||||
<div className="card divide-y divide-gray-100">
|
<div className="card divide-y divide-gray-100">
|
||||||
<div style={{ padding: '16px 20px' }}>
|
<div style={{ padding: '16px 20px' }}>
|
||||||
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||||
@@ -650,21 +952,29 @@ export default function PrintFontsTab() {
|
|||||||
{FONT_GROUPS.map(group => (
|
{FONT_GROUPS.map(group => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
<SubgroupHeader label={group.group} />
|
<SubgroupHeader label={group.group} />
|
||||||
{group.fields.map((field, idx) => (
|
{group.fields.map((field) => (
|
||||||
|
<React.Fragment key={field.key}>
|
||||||
<FontRow
|
<FontRow
|
||||||
key={field.key}
|
|
||||||
field={field}
|
field={field}
|
||||||
value={val(field.key)}
|
value={val(field.key)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isPending={updateMut.isPending}
|
isPending={updateMut.isPending}
|
||||||
nested={group.fields.length > 1}
|
nested={group.fields.length > 1}
|
||||||
/>
|
/>
|
||||||
|
{field.key === 'print.font_ingredient' && (
|
||||||
|
<DotStyleRow
|
||||||
|
value={val('print.dot_style')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Divider style */}
|
{/* 5. Divider style */}
|
||||||
<div className="card divide-y divide-gray-100">
|
<div className="card divide-y divide-gray-100">
|
||||||
<div style={{ padding: '16px 20px' }}>
|
<div style={{ padding: '16px 20px' }}>
|
||||||
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
@@ -46,15 +46,30 @@ function PinInput({ value, onChange }) {
|
|||||||
onChange(value + d)
|
onChange(value + d)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||||||
<div className="flex justify-center gap-2">
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className={`w-3 h-3 rounded-full border-2 ${i < value.length ? 'bg-primary-700 border-primary-700' : 'border-gray-300'}`} />
|
<div key={i} style={{
|
||||||
|
width: 13, height: 13, borderRadius: '50%',
|
||||||
|
border: `2px solid ${i < value.length ? '#111827' : '#d1d5db'}`,
|
||||||
|
background: i < value.length ? '#111827' : 'transparent',
|
||||||
|
transition: 'all 150ms',
|
||||||
|
}} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, width: '100%' }}>
|
||||||
{DIGITS.map((d, i) => (
|
{DIGITS.map((d, i) => (
|
||||||
<button key={i} type="button" onClick={() => press(d)} disabled={d === ''} className={`h-11 rounded-xl text-lg font-semibold transition-colors ${d === '' ? 'invisible' : d === '⌫' ? 'bg-gray-100 hover:bg-gray-200 text-gray-600' : 'bg-gray-100 hover:bg-primary-100 text-gray-800'}`}>
|
<button
|
||||||
|
key={i} type="button" onClick={() => press(d)} disabled={d === ''}
|
||||||
|
style={{
|
||||||
|
height: 46, borderRadius: 10, fontSize: 18, fontWeight: 600, border: 'none',
|
||||||
|
cursor: d === '' ? 'default' : 'pointer',
|
||||||
|
background: d === '' ? 'transparent' : d === '⌫' ? '#f3f4f6' : '#f8fafc',
|
||||||
|
color: d === '⌫' ? '#6b7280' : '#111827',
|
||||||
|
transition: 'background 100ms',
|
||||||
|
visibility: d === '' ? 'hidden' : 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{d}
|
{d}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -63,12 +78,109 @@ function PinInput({ value, onChange }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Actions dropdown menu ─────────────────────────────────────────────────────
|
||||||
|
function ActionsMenu({ waiter, onEdit, onZones, onResetPin, onBlock, onDelete }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handler(e) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Επεξεργασία', icon: editIcon, onClick: onEdit },
|
||||||
|
waiter.role === 'waiter' && { label: 'Πρόσβαση σε Ζώνες', icon: zonesIcon, onClick: onZones },
|
||||||
|
{ label: 'Επαναφορά PIN', icon: pinIcon, onClick: onResetPin },
|
||||||
|
{ label: waiter.is_active ? 'Μπλοκάρισμα' : 'Ξεμπλοκάρισμα', icon: blockIcon, onClick: onBlock, color: waiter.is_active ? '#f97316' : '#22c55e' },
|
||||||
|
{ label: 'Διαγραφή', icon: trashIcon, onClick: onDelete, color: '#ef4444' },
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 13, padding: '0 12px', height: 34, borderRadius: 8,
|
||||||
|
border: '1px solid #e5e7eb', background: open ? '#f3f4f6' : '#fff',
|
||||||
|
color: '#374151', fontWeight: 500, cursor: 'pointer',
|
||||||
|
transition: 'background 120ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ενέργειες
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 100,
|
||||||
|
background: '#fff', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||||
|
minWidth: 200, overflow: 'hidden', padding: '4px 0',
|
||||||
|
}}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => { setOpen(false); item.onClick() }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
width: '100%', padding: '9px 14px', border: 'none',
|
||||||
|
background: 'transparent', cursor: 'pointer', textAlign: 'left',
|
||||||
|
fontSize: 13.5, fontWeight: 500,
|
||||||
|
color: item.color || '#111827',
|
||||||
|
transition: 'background 80ms',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = item.color ? `${item.color}10` : '#f9fafb'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<span style={{ color: item.color || '#6b7280', display: 'flex', flexShrink: 0 }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const zonesIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const pinIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const blockIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const trashIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 6h18" /><path d="M8 6V4h8v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ZoneModal ─────────────────────────────────────────────────────────────────
|
||||||
function ZoneModal({ waiter, groups, onClose }) {
|
function ZoneModal({ waiter, groups, onClose }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
// Derive initial state from waiter's zone_assignments
|
|
||||||
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
|
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
|
||||||
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
|
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
|
||||||
|
|
||||||
const [allZones, setAllZones] = useState(hasAllZones)
|
const [allZones, setAllZones] = useState(hasAllZones)
|
||||||
const [selected, setSelected] = useState(new Set(assignedIds))
|
const [selected, setSelected] = useState(new Set(assignedIds))
|
||||||
|
|
||||||
@@ -79,91 +191,93 @@ function ZoneModal({ waiter, groups, onClose }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function toggleGroup(gid) {
|
function toggleGroup(gid) {
|
||||||
setSelected(prev => {
|
setSelected(prev => { const next = new Set(prev); next.has(gid) ? next.delete(gid) : next.add(gid); return next })
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(gid)) next.delete(gid); else next.add(gid)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
if (allZones) {
|
|
||||||
saveZones.mutate({ all_zones: true, group_ids: [] })
|
|
||||||
} else {
|
|
||||||
saveZones.mutate({ all_zones: false, group_ids: [...selected] })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||||
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
<input
|
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={allZones}
|
||||||
type="checkbox"
|
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }} />
|
||||||
className="w-5 h-5 rounded accent-primary-700"
|
|
||||||
checked={allZones}
|
|
||||||
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
|
|
||||||
/>
|
|
||||||
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
|
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!allZones && (
|
{!allZones && (
|
||||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && <p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>}
|
||||||
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
|
|
||||||
)}
|
|
||||||
{groups.map(g => (
|
{groups.map(g => (
|
||||||
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
||||||
<input
|
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={selected.has(g.id)} onChange={() => toggleGroup(g.id)} />
|
||||||
type="checkbox"
|
|
||||||
className="w-5 h-5 rounded accent-primary-700"
|
|
||||||
checked={selected.has(g.id)}
|
|
||||||
onChange={() => toggleGroup(g.id)}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">{g.name}</span>
|
<span className="text-gray-700">{g.name}</span>
|
||||||
{g.color && (
|
{g.color && <span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />}
|
||||||
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!allZones && selected.size === 0 && (
|
{!allZones && selected.size === 0 && (
|
||||||
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
|
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.</p>
|
||||||
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
|
<button onClick={() => saveZones.mutate(allZones ? { all_zones: true, group_ids: [] } : { all_zones: false, group_ids: [...selected] })}
|
||||||
Αποθήκευση
|
disabled={saveZones.isPending} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared form primitives ────────────────────────────────────────────────────
|
||||||
|
function FormField({ label, required, desc, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151' }}>
|
||||||
|
{label}{required && <span style={{ color: '#ef4444', marginLeft: 2 }}>*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{desc && <p style={{ margin: 0, fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>{desc}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter', pin: '' }
|
function SectionLabel({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#9ca3af', marginBottom: 10 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoBlock({ label, value, valueColor }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<span style={{ fontSize: 10.5, fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 13.5, fontWeight: 500, color: valueColor || '#111827' }}>{value || '—'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb', borderRadius: 7,
|
||||||
|
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '' }
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function WaitersPage() {
|
export default function WaitersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [addModal, setAddModal] = useState(false)
|
const [addModal, setAddModal] = useState(false)
|
||||||
const [pinModal, setPinModal] = useState(null) // waiter id
|
const [pinModal, setPinModal] = useState(null)
|
||||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
const [zoneModal, setZoneModal] = useState(null)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
const [newForm, setNewForm] = useState(EMPTY_FORM)
|
const [newForm, setNewForm] = useState(EMPTY_FORM)
|
||||||
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
||||||
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
||||||
|
const [editModal, setEditModal] = useState(null)
|
||||||
const [editModal, setEditModal] = useState(null) // waiter object
|
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '' })
|
||||||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter' })
|
|
||||||
const avatarInputRef = useRef(null)
|
const avatarInputRef = useRef(null)
|
||||||
const newAvatarInputRef = useRef(null)
|
const newAvatarInputRef = useRef(null)
|
||||||
|
|
||||||
@@ -171,7 +285,6 @@ export default function WaitersPage() {
|
|||||||
queryKey: ['waiters'],
|
queryKey: ['waiters'],
|
||||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: groups = [] } = useQuery({
|
const { data: groups = [] } = useQuery({
|
||||||
queryKey: ['table-groups'],
|
queryKey: ['table-groups'],
|
||||||
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||||||
@@ -191,10 +304,7 @@ export default function WaitersPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Σερβιτόρος δημιουργήθηκε')
|
toast.success('Σερβιτόρος δημιουργήθηκε')
|
||||||
setAddModal(false)
|
setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null)
|
||||||
setNewForm(EMPTY_FORM)
|
|
||||||
setNewAvatarFile(null)
|
|
||||||
setNewAvatarPreview(null)
|
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||||
@@ -208,31 +318,22 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
const uploadAvatar = useMutation({
|
const uploadAvatar = useMutation({
|
||||||
mutationFn: ({ id, file }) => {
|
mutationFn: ({ id, file }) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData(); fd.append('file', file)
|
||||||
fd.append('file', file)
|
|
||||||
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => { toast.success('Avatar ανέβηκε'); setEditModal(res.data); invalidate() },
|
||||||
toast.success('Avatar ανέβηκε')
|
|
||||||
setEditModal(res.data)
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteAvatar = useMutation({
|
const deleteAvatar = useMutation({
|
||||||
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => { toast.success('Avatar αφαιρέθηκε'); setEditModal(res.data); invalidate() },
|
||||||
toast.success('Avatar αφαιρέθηκε')
|
|
||||||
setEditModal(res.data)
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleBlock = useMutation({
|
const toggleBlock = useMutation({
|
||||||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||||
onSuccess: () => { invalidate() },
|
onSuccess: () => invalidate(),
|
||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,9 +351,13 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||||
|
|
||||||
|
function openEdit(w) {
|
||||||
|
setEditModal(w)
|
||||||
|
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center justify-end gap-2 border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
|
<div className="flex items-center justify-end gap-2 border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
|
||||||
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
|
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,6 +370,7 @@ export default function WaitersPage() {
|
|||||||
{waiters.map(w => (
|
{waiters.map(w => (
|
||||||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||||
<WaiterAvatar waiter={w} size={44} />
|
<WaiterAvatar waiter={w} size={44} />
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
||||||
@@ -274,202 +380,86 @@ export default function WaitersPage() {
|
|||||||
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
||||||
{w.role === 'waiter' && (
|
{w.role === 'waiter' && (
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
{w.zone_assignments.length === 0
|
{w.zone_assignments.length === 0 ? 'Χωρίς ζώνες'
|
||||||
? 'Χωρίς ζώνες'
|
: w.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
|
||||||
: w.zone_assignments.some(z => z.group_id === null)
|
|
||||||
? 'Όλες οι ζώνες'
|
|
||||||
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
|
||||||
|
{/* Active status badge — only this stays inline */}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11.5, fontWeight: 600, padding: '3px 9px', borderRadius: 999,
|
||||||
|
background: w.is_active ? '#dcfce7' : '#fee2e2',
|
||||||
|
color: w.is_active ? '#15803d' : '#dc2626',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', role: w.role || 'waiter' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
|
||||||
{w.role === 'waiter' && (
|
{/* Single actions dropdown */}
|
||||||
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
|
<ActionsMenu
|
||||||
)}
|
waiter={w}
|
||||||
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
|
onEdit={() => openEdit(w)}
|
||||||
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
|
onZones={() => setZoneModal(w)}
|
||||||
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
|
onResetPin={() => setPinModal(w.id)}
|
||||||
</button>
|
onBlock={() => toggleBlock.mutate(w.id)}
|
||||||
<button onClick={() => setConfirmDelete(w.id)} className="btn btn-ghost text-sm px-2 py-1.5 min-h-0 h-9 text-red-500 hover:bg-red-50">🗑</button>
|
onDelete={() => setConfirmDelete(w.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add waiter modal */}
|
{/* Add modal */}
|
||||||
{addModal && (
|
{addModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<AddWaiterModal
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
form={newForm} setForm={setNewForm}
|
||||||
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
avatarFile={newAvatarFile} avatarPreview={newAvatarPreview}
|
||||||
|
setAvatarFile={setNewAvatarFile} setAvatarPreview={setNewAvatarPreview}
|
||||||
{/* Avatar picker */}
|
avatarInputRef={newAvatarInputRef}
|
||||||
<div className="flex items-center gap-4">
|
isPending={createWaiter.isPending}
|
||||||
{newAvatarPreview ? (
|
onClose={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }}
|
||||||
<img src={newAvatarPreview} alt="preview" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
onSubmit={() => createWaiter.mutate({
|
||||||
) : (
|
username: newForm.username, full_name: newForm.full_name || null,
|
||||||
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null,
|
||||||
<span style={{ fontSize: 28, color: '#9ca3af' }}>👤</span>
|
email: newForm.email || null, note: newForm.note || null,
|
||||||
</div>
|
pin: newForm.pin, role: newForm.role, is_active: true,
|
||||||
)}
|
hourly_rate: newForm.hourly_rate !== '' ? parseFloat(newForm.hourly_rate) : null,
|
||||||
<div className="flex flex-col gap-2">
|
})}
|
||||||
<input
|
|
||||||
ref={newAvatarInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
setNewAvatarFile(file)
|
|
||||||
setNewAvatarPreview(URL.createObjectURL(file))
|
|
||||||
}
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button onClick={() => newAvatarInputRef.current?.click()} type="button" className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8">
|
|
||||||
{newAvatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
|
||||||
</button>
|
|
||||||
{newAvatarPreview && (
|
|
||||||
<button type="button" onClick={() => { setNewAvatarFile(null); setNewAvatarPreview(null) }} className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50">
|
|
||||||
Αφαίρεση
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Πλήρες όνομα *</label>
|
|
||||||
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Παρατσούκλι (nickname) *</label>
|
|
||||||
<input className="input" placeholder="π.χ. Γιώργος" value={newForm.nickname} onChange={e => setNewForm(f => ({ ...f, nickname: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Κινητό τηλέφωνο</label>
|
|
||||||
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Όνομα χρήστη *</label>
|
|
||||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Ρόλος *</label>
|
|
||||||
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
|
|
||||||
<option value="waiter">Σερβιτόρος</option>
|
|
||||||
<option value="manager">Διαχειριστής</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label mb-2">PIN *</label>
|
|
||||||
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button onClick={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
|
||||||
<button
|
|
||||||
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
|
|
||||||
disabled={createWaiter.isPending || !newForm.username.trim() || !newForm.full_name.trim() || !newForm.nickname.trim() || newForm.pin.length < 4}
|
|
||||||
className="flex-1 btn btn-primary"
|
|
||||||
>
|
|
||||||
{createWaiter.isPending ? 'Δημιουργία…' : 'Δημιουργία'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit profile modal */}
|
{/* Edit modal */}
|
||||||
{editModal && (
|
{editModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<EditWaiterModal
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
waiter={editModal} form={editForm} setForm={setEditForm}
|
||||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.full_name || editModal.username}</h2>
|
avatarInputRef={avatarInputRef}
|
||||||
|
isPending={updateWaiter.isPending}
|
||||||
{/* Avatar section */}
|
isUploadPending={uploadAvatar.isPending}
|
||||||
<div className="flex items-center gap-4">
|
isDeletePending={deleteAvatar.isPending}
|
||||||
<WaiterAvatar waiter={editModal} size={64} />
|
onClose={() => setEditModal(null)}
|
||||||
<div className="flex flex-col gap-2">
|
onSubmit={() => updateWaiter.mutate({
|
||||||
<input
|
id: editModal.id,
|
||||||
ref={avatarInputRef}
|
username: editForm.username.trim() || undefined,
|
||||||
type="file"
|
full_name: editForm.full_name || null, nickname: editForm.nickname || null,
|
||||||
accept="image/*"
|
mobile_phone: editForm.mobile_phone || null, email: editForm.email || null,
|
||||||
className="hidden"
|
note: editForm.note || null, role: editForm.role,
|
||||||
onChange={e => {
|
hourly_rate: editForm.hourly_rate !== '' ? parseFloat(editForm.hourly_rate) : null,
|
||||||
const file = e.target.files?.[0]
|
})}
|
||||||
if (file) uploadAvatar.mutate({ id: editModal.id, file })
|
onUploadAvatar={file => uploadAvatar.mutate({ id: editModal.id, file })}
|
||||||
e.target.value = ''
|
onDeleteAvatar={() => deleteAvatar.mutate(editModal.id)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => avatarInputRef.current?.click()}
|
|
||||||
disabled={uploadAvatar.isPending}
|
|
||||||
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
|
||||||
>
|
|
||||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
|
||||||
</button>
|
|
||||||
{editModal.avatar_url && (
|
|
||||||
<button
|
|
||||||
onClick={() => deleteAvatar.mutate(editModal.id)}
|
|
||||||
disabled={deleteAvatar.isPending}
|
|
||||||
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
Αφαίρεση
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Πλήρες όνομα *</label>
|
|
||||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Παρατσούκλι (nickname) *</label>
|
|
||||||
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Κινητό τηλέφωνο</label>
|
|
||||||
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Όνομα χρήστη *</label>
|
|
||||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Ρόλος *</label>
|
|
||||||
<select className="input" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}>
|
|
||||||
<option value="waiter">Σερβιτόρος</option>
|
|
||||||
<option value="manager">Διαχειριστής</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
|
||||||
<button
|
|
||||||
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null, role: editForm.role })}
|
|
||||||
disabled={updateWaiter.isPending || !editForm.username.trim() || !editForm.full_name.trim() || !editForm.nickname.trim()}
|
|
||||||
className="flex-1 btn btn-primary"
|
|
||||||
>
|
|
||||||
{updateWaiter.isPending ? 'Αποθήκευση…' : 'Αποθήκευση'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reset PIN modal */}
|
{/* Reset PIN modal */}
|
||||||
{pinModal !== null && (
|
{pinModal !== null && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
||||||
<h2 className="font-bold text-gray-800">Reset PIN — {waiters.find(w => w.id === pinModal)?.username}</h2>
|
<h2 className="font-bold text-gray-800">Επαναφορά PIN — {waiters.find(w => w.id === pinModal)?.username}</h2>
|
||||||
<PinInput value={newPin} onChange={setNewPin} />
|
<PinInput value={newPin} onChange={setNewPin} />
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
<button
|
<button onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })} disabled={newPin.length < 4} className="flex-1 btn btn-primary">
|
||||||
onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })}
|
|
||||||
disabled={newPin.length < 4}
|
|
||||||
className="flex-1 btn btn-primary"
|
|
||||||
>
|
|
||||||
Αποθήκευση
|
Αποθήκευση
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,3 +485,329 @@ export default function WaitersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add Waiter Modal — two-column ─────────────────────────────────────────────
|
||||||
|
function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFile, setAvatarPreview, avatarInputRef, isPending, onClose, onSubmit }) {
|
||||||
|
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
|
||||||
|
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim() && form.pin.length >= 4
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 50, padding: 24,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
|
||||||
|
width: '100%', maxWidth: 980,
|
||||||
|
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||||||
|
{avatarPreview
|
||||||
|
? <img src={avatarPreview} alt="preview" style={{ width: 48, height: 48, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||||
|
: <div style={{ width: 48, height: 48, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 22, color: '#9ca3af' }}>👤</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>Νέος σερβιτόρος</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Συμπληρώστε τα στοιχεία του εργαζόμενου</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) { setAvatarFile(file); setAvatarPreview(URL.createObjectURL(file)) }
|
||||||
|
e.target.value = ''
|
||||||
|
}} />
|
||||||
|
<button onClick={() => avatarInputRef.current?.click()} type="button"
|
||||||
|
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||||||
|
{avatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||||
|
</button>
|
||||||
|
{avatarPreview && (
|
||||||
|
<button type="button" onClick={() => { setAvatarFile(null); setAvatarPreview(null) }}
|
||||||
|
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
|
||||||
|
Αφαίρεση
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — three columns, scrollable */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
|
||||||
|
{/* Column 1: Identity + Account */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Ταυτότητα</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
|
||||||
|
<input style={inputStyle} placeholder="π.χ. Γιώργος Παπαδόπουλος" value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
|
||||||
|
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Λογαριασμός</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
|
||||||
|
<input style={inputStyle} placeholder="π.χ. giorgos" value={form.username} onChange={e => f('username', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
|
||||||
|
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
|
||||||
|
<option value="waiter">Σερβιτόρος</option>
|
||||||
|
<option value="manager">Διαχειριστής</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 2: Contact info */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Επικοινωνία</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
|
||||||
|
<input style={inputStyle} placeholder="π.χ. 6901234567" value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
|
||||||
|
<input style={inputStyle} type="email" placeholder="π.χ. giorgos@restaurant.gr" value={form.email} onChange={e => f('email', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none' }} placeholder="π.χ. Μερική απασχόληση" value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 3: Payroll + PIN */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Μισθοδοσία</SectionLabel>
|
||||||
|
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number" step="0.50" min="0"
|
||||||
|
style={{ ...inputStyle, width: 110 }}
|
||||||
|
placeholder="π.χ. 5.50"
|
||||||
|
value={form.hourly_rate ?? ''}
|
||||||
|
onChange={e => f('hourly_rate', e.target.value)}
|
||||||
|
/>
|
||||||
|
{form.hourly_rate && parseFloat(form.hourly_rate) > 0 && (
|
||||||
|
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
|
||||||
|
€{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!form.hourly_rate && (
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<SectionLabel>Κωδικός PIN</SectionLabel>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||||
|
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
|
||||||
|
</p>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 8 }}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 220 }}>
|
||||||
|
<PinInput value={form.pin} onChange={pin => f('pin', pin)} />
|
||||||
|
{form.pin.length > 0 && form.pin.length < 4 && (
|
||||||
|
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#f59e0b', marginTop: 10 }}>
|
||||||
|
Χρειάζονται {4 - form.pin.length} ψηφία ακόμη
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.pin.length >= 4 && (
|
||||||
|
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#22c55e', marginTop: 10, fontWeight: 600 }}>
|
||||||
|
✓ PIN ορίστηκε
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
<button onClick={onSubmit} disabled={isPending || !canSubmit}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
|
||||||
|
{isPending ? 'Δημιουργία…' : 'Δημιουργία σερβιτόρου'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Waiter Modal — three-column ──────────────────────────────────────────
|
||||||
|
function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isUploadPending, isDeletePending, onClose, onSubmit, onUploadAvatar, onDeleteAvatar }) {
|
||||||
|
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
|
||||||
|
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim()
|
||||||
|
|
||||||
|
const zoneLabel = !waiter.zone_assignments?.length ? 'Χωρίς ζώνες'
|
||||||
|
: waiter.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
|
||||||
|
: `${waiter.zone_assignments.length} ζών${waiter.zone_assignments.length === 1 ? 'η' : 'ες'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 50, padding: 24,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
|
||||||
|
width: '100%', maxWidth: 980,
|
||||||
|
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||||||
|
<WaiterAvatar waiter={waiter} size={48} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>
|
||||||
|
Επεξεργασία — {waiter.full_name || waiter.username}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Ενημέρωση στοιχείων εργαζόμενου</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||||||
|
onChange={e => { const file = e.target.files?.[0]; if (file) onUploadAvatar(file); e.target.value = '' }} />
|
||||||
|
<button onClick={() => avatarInputRef.current?.click()} disabled={isUploadPending}
|
||||||
|
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||||||
|
{isUploadPending ? 'Μεταφόρτωση…' : waiter.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||||
|
</button>
|
||||||
|
{waiter.avatar_url && (
|
||||||
|
<button onClick={onDeleteAvatar} disabled={isDeletePending}
|
||||||
|
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
|
||||||
|
Αφαίρεση
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — three columns, scrollable */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
|
||||||
|
{/* Column 1: Identity + Account */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Ταυτότητα</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
|
||||||
|
<input style={inputStyle} value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
|
||||||
|
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Λογαριασμός</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
|
||||||
|
<input style={inputStyle} value={form.username} onChange={e => f('username', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
|
||||||
|
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
|
||||||
|
<option value="waiter">Σερβιτόρος</option>
|
||||||
|
<option value="manager">Διαχειριστής</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 2: Contact info */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Επικοινωνία</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
|
||||||
|
<input style={inputStyle} value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
|
||||||
|
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
|
||||||
|
<textarea style={{ ...inputStyle, resize: 'none' }} value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 3: Info + Payroll + PIN note */}
|
||||||
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Πληροφορίες</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{/* Row 1: Date + Status side by side */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
<InfoBlock label="Εγγραφή"
|
||||||
|
value={waiter.created_at ? new Date(waiter.created_at).toLocaleDateString('el-GR') : null} />
|
||||||
|
<InfoBlock label="Κατάσταση"
|
||||||
|
value={waiter.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||||
|
valueColor={waiter.is_active ? '#16a34a' : '#ef4444'} />
|
||||||
|
</div>
|
||||||
|
<InfoBlock label="Ρόλος"
|
||||||
|
value={waiter.role === 'waiter' ? 'Σερβιτόρος' : 'Διαχειριστής'} />
|
||||||
|
{waiter.role === 'waiter' && (
|
||||||
|
<InfoBlock label="Ζώνες" value={zoneLabel} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase 2B — Payroll */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Μισθοδοσία</SectionLabel>
|
||||||
|
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number" step="0.50" min="0"
|
||||||
|
style={{ ...inputStyle, width: 110 }}
|
||||||
|
placeholder="π.χ. 5.50"
|
||||||
|
value={form.hourly_rate}
|
||||||
|
onChange={e => f('hourly_rate', e.target.value)}
|
||||||
|
/>
|
||||||
|
{form.hourly_rate !== '' && parseFloat(form.hourly_rate) > 0 && (
|
||||||
|
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
|
||||||
|
€{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{form.hourly_rate === '' && (
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
|
||||||
|
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
|
||||||
|
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
|
||||||
|
Για αλλαγή PIN χρησιμοποιήστε την επιλογή <strong style={{ color: '#374151' }}>Επαναφορά PIN</strong> από το μενού Ενεργειών.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
<button onClick={onSubmit} disabled={isPending || !canSubmit}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Αποθήκευση αλλαγών'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
@@ -43,6 +43,8 @@ export default function TablesPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
||||||
const [selected, setSelected] = useState(new Set())
|
const [selected, setSelected] = useState(new Set())
|
||||||
const [anyHovered, setAnyHovered] = useState(false)
|
const [anyHovered, setAnyHovered] = useState(false)
|
||||||
|
const dragGroupId = useRef(null)
|
||||||
|
const [dragOverGap, setDragOverGap] = useState(null) // index into groups array (0 = before first)
|
||||||
|
|
||||||
const { data: tables = [], isLoading } = useQuery({
|
const { data: tables = [], isLoading } = useQuery({
|
||||||
queryKey: ['tables-all', showInactive],
|
queryKey: ['tables-all', showInactive],
|
||||||
@@ -102,6 +104,12 @@ export default function TablesPage() {
|
|||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reorderGroups = useMutation({
|
||||||
|
mutationFn: (ids) => client.put('/api/tables/groups/reorder', ids),
|
||||||
|
onSuccess: invalidateGroups,
|
||||||
|
onError: () => toast.error('Σφάλμα αναδιάταξης'),
|
||||||
|
})
|
||||||
|
|
||||||
// Filter tables for the active tab
|
// Filter tables for the active tab
|
||||||
const visibleTables = activeTab === 'all'
|
const visibleTables = activeTab === 'all'
|
||||||
? tables
|
? tables
|
||||||
@@ -156,23 +164,74 @@ export default function TablesPage() {
|
|||||||
|
|
||||||
{/* Zone tabs */}
|
{/* Zone tabs */}
|
||||||
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
||||||
{zoneTabs.map(tab => (
|
{zoneTabs.map(tab => {
|
||||||
|
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
|
||||||
|
const groupIdx = isGroup ? groups.findIndex(g => g.id === tab.id) : -1
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Use mouse position within the tab to decide left-half vs right-half gap
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const isLeftHalf = e.clientX < rect.left + rect.width / 2
|
||||||
|
setDragOverGap(isLeftHalf ? groupIdx : groupIdx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fromId = dragGroupId.current
|
||||||
|
dragGroupId.current = null
|
||||||
|
setDragOverGap(null)
|
||||||
|
if (!fromId) return
|
||||||
|
const groupIds = groups.map(g => g.id)
|
||||||
|
const fromIdx = groupIds.indexOf(fromId)
|
||||||
|
if (fromIdx === -1 || dragOverGap === null) return
|
||||||
|
const reordered = [...groupIds]
|
||||||
|
reordered.splice(fromIdx, 1)
|
||||||
|
// After removing the dragged item, adjust target index
|
||||||
|
const insertAt = dragOverGap > fromIdx ? dragOverGap - 1 : dragOverGap
|
||||||
|
reordered.splice(insertAt, 0, fromId)
|
||||||
|
if (reordered.join() !== groupIds.join()) reorderGroups.mutate(reordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLineLeft = isGroup && dragOverGap === groupIdx
|
||||||
|
const showLineRight = isGroup && dragOverGap === groupIdx + 1 && groupIdx === groups.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tab.id} className="relative flex items-stretch">
|
||||||
|
{/* Drop indicator line — left side of this tab */}
|
||||||
|
{showLineLeft && (
|
||||||
|
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 -translate-x-px" />
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
|
||||||
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
||||||
|
draggable={isGroup}
|
||||||
|
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
|
||||||
|
onDragOver={isGroup ? handleDragOver : undefined}
|
||||||
|
onDragLeave={isGroup ? () => setDragOverGap(null) : undefined}
|
||||||
|
onDrop={isGroup ? handleDrop : undefined}
|
||||||
|
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverGap(null) } : undefined}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-sky-500 text-sky-600'
|
? 'border-sky-500 text-sky-600'
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||||
}`}
|
} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
|
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none">⠿</span>}
|
||||||
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
<span className="ml-0.5 text-xs text-slate-400">
|
<span className="ml-0.5 text-xs text-slate-400">
|
||||||
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
|
{/* Drop indicator line — right side of the last group tab */}
|
||||||
|
{showLineRight && (
|
||||||
|
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 translate-x-px" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zone action bar (when viewing a specific zone) */}
|
{/* Zone action bar (when viewing a specific zone) */}
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ function FlagPills({ flags, displayMode = 'both', onOverflowClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', onClick, onQuickAction, onFlagsClick }) {
|
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', upcomingReservation = null, onClick, onQuickAction, onFlagsClick }) {
|
||||||
const s = COLORS[status] || COLORS.free
|
const s = COLORS[status] || COLORS.free
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
const [pressed, setPressed] = useState(false)
|
const [pressed, setPressed] = useState(false)
|
||||||
@@ -348,6 +348,15 @@ function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingP
|
|||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
}}>⏳</span>
|
}}>⏳</span>
|
||||||
)}
|
)}
|
||||||
|
{upcomingReservation && (
|
||||||
|
<span title={`${upcomingReservation.guest_name} · ${upcomingReservation.party_size} άτομα · ${new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}`} style={{
|
||||||
|
fontSize: 11, fontWeight: 700, background: '#4f46e5', color: '#e0e7ff',
|
||||||
|
borderRadius: 999, padding: '2px 8px', flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
🔒 RESERVED · {new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<FlagPills
|
<FlagPills
|
||||||
flags={flags}
|
flags={flags}
|
||||||
displayMode={flagDisplayMode}
|
displayMode={flagDisplayMode}
|
||||||
@@ -741,6 +750,7 @@ export default function TablesPage() {
|
|||||||
hasPendingPrint={hasPendingPrint}
|
hasPendingPrint={hasPendingPrint}
|
||||||
flags={tableFlags}
|
flags={tableFlags}
|
||||||
flagDisplayMode={flagDisplayMode}
|
flagDisplayMode={flagDisplayMode}
|
||||||
|
upcomingReservation={table.upcoming_reservation ?? null}
|
||||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||||
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
|
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
|
||||||
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
|
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
|
||||||
|
|||||||
342
manager_dashboard/src/pages/TabsPage.jsx
Normal file
342
manager_dashboard/src/pages/TabsPage.jsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
|
||||||
|
if (days === 0) return 'Σήμερα'
|
||||||
|
if (days === 1) return '1 μέρα'
|
||||||
|
return `${days} μέρες`
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
open: { label: 'Ανοιχτή', bg: '#fee2e2', color: '#dc2626' },
|
||||||
|
closed: { label: 'Κλειστή', bg: '#dcfce7', color: '#16a34a' },
|
||||||
|
forgiven: { label: 'Χαρίστηκε', bg: '#f3f4f6', color: '#6b7280' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pay modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PayModal({ tab, onClose, onPay, isPending }) {
|
||||||
|
const [amount, setAmount] = useState(String(Math.round(tab.balance * 100) / 100))
|
||||||
|
const [method, setMethod] = useState('cash')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const parsed = parseFloat(amount)
|
||||||
|
const canPay = parsed > 0 && parsed <= tab.balance + 0.005
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700 }}>Πληρωμή Καρτέλας</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#6b7280' }}>Υπόλοιπο καρτέλας</span>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(tab.balance)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής (€)</label>
|
||||||
|
<input type="number" step="0.01" min="0.01"
|
||||||
|
value={amount} onChange={e => setAmount(e.target.value)} autoFocus
|
||||||
|
style={{ width: '100%', padding: '9px 12px', border: '1.5px solid #e5e7eb', borderRadius: 8, fontSize: 16, fontWeight: 600, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τρόπος πληρωμής</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{[['cash', 'Μετρητά'], ['card', 'Κάρτα'], ['other', 'Άλλο']].map(([v, l]) => (
|
||||||
|
<button key={v} onClick={() => setMethod(v)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '7px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1.5px solid ${method === v ? '#111827' : '#e5e7eb'}`,
|
||||||
|
background: method === v ? '#111827' : 'white',
|
||||||
|
color: method === v ? 'white' : '#6b7280',
|
||||||
|
}}>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
|
||||||
|
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="προαιρετικό"
|
||||||
|
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button onClick={() => onPay(parsed, method, notes || null)} disabled={!canPay || isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forgive confirm ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ForgiveModal({ tab, onClose, onForgive, isPending }) {
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||||
|
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#dc2626' }}>Χάρισμα Καρτέλας</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name} — υπόλοιπο {fmt(tab.balance)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '12px 14px', background: '#fef2f2', borderRadius: 10, border: '1px solid #fecaca', fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
|
||||||
|
Το υπόλοιπο των {fmt(tab.balance)} θα διαγραφεί. Η ενέργεια δεν αναιρείται.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Αιτιολογία</label>
|
||||||
|
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="π.χ. Καλή θέληση"
|
||||||
|
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||||
|
<button onClick={() => onForgive(reason || null)} disabled={isPending}
|
||||||
|
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: '#dc2626', color: 'white' }}>
|
||||||
|
{isPending ? 'Αποθήκευση…' : 'Χάρισμα Υπολοίπου'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab detail card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabDetail({ tab, onPay, onClose, onForgive }) {
|
||||||
|
const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open
|
||||||
|
const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 18px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||||
|
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
|
||||||
|
{fmt(tab.balance)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
|
||||||
|
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
|
||||||
|
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
{tab.entries.length > 0 && (
|
||||||
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{tab.entries.map(e => (
|
||||||
|
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
{tab.payments.length > 0 && (
|
||||||
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{tab.payments.map(p => (
|
||||||
|
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span style={{ color: '#374151' }}>
|
||||||
|
{p.received_by_name} · {fmtDateTime(p.created_at)}
|
||||||
|
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
|
||||||
|
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}>— {p.notes}</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{tab.status === 'open' && (
|
||||||
|
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{tab.balance > 0.005 && (
|
||||||
|
<button onClick={() => onPay(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||||
|
Πληρωμή
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canClose && (
|
||||||
|
<button onClick={() => onClose(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||||
|
Κλείσιμο Καρτέλας
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onForgive(tab)}
|
||||||
|
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||||
|
Χάρισμα Υπολοίπου
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TabsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
const [modal, setModal] = useState(null) // { type: 'pay'|'forgive', tab }
|
||||||
|
|
||||||
|
const { data: tabs = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['tabs', showAll],
|
||||||
|
queryFn: () => client.get('/api/tabs/', { params: showAll ? { tab_status: 'all' } : {} }).then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refetch tabs with all statuses when toggled
|
||||||
|
const { data: allTabs = [] } = useQuery({
|
||||||
|
queryKey: ['tabs-all'],
|
||||||
|
queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: showAll,
|
||||||
|
})
|
||||||
|
|
||||||
|
const payTab = useMutation({
|
||||||
|
mutationFn: ({ id, amount, payment_method, notes }) =>
|
||||||
|
client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['tabs-all'] })
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Πληρωμή καταγράφηκε')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeTab = useMutation({
|
||||||
|
mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||||
|
toast.success('Καρτέλα έκλεισε')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const forgiveTab = useMutation({
|
||||||
|
mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Το υπόλοιπο χαρίστηκε')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openTabs = tabs.filter(t => t.status === 'open')
|
||||||
|
const totalOutstanding = openTabs.reduce((s, t) => s + t.balance, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Καρτέλες</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
{openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '}
|
||||||
|
<strong style={{ color: totalOutstanding > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(s => !s)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#6b7280' }}
|
||||||
|
>
|
||||||
|
{showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||||
|
{!isLoading && openTabs.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 14, padding: '48px 0' }}>
|
||||||
|
Δεν υπάρχουν ανοιχτές καρτέλες.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{openTabs.map(tab => (
|
||||||
|
<TabDetail
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
onPay={t => setModal({ type: 'pay', tab: t })}
|
||||||
|
onClose={t => closeTab.mutate(t.id)}
|
||||||
|
onForgive={t => setModal({ type: 'forgive', tab: t })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAll && allTabs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 8 }}>Κλειστές / Χαρισμένες</div>
|
||||||
|
{allTabs.map(tab => (
|
||||||
|
<TabDetail key={tab.id} tab={tab} onPay={() => {}} onClose={() => {}} onForgive={() => {}} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal?.type === 'pay' && (
|
||||||
|
<PayModal
|
||||||
|
tab={modal.tab}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={payTab.isPending}
|
||||||
|
onPay={(amount, payment_method, notes) => payTab.mutate({ id: modal.tab.id, amount, payment_method, notes })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal?.type === 'forgive' && (
|
||||||
|
<ForgiveModal
|
||||||
|
tab={modal.tab}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
isPending={forgiveTab.isPending}
|
||||||
|
onForgive={(reason) => forgiveTab.mutate({ id: modal.tab.id, reason })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
296
manager_dashboard/src/pages/WastePage.jsx
Normal file
296
manager_dashboard/src/pages/WastePage.jsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REASONS = [
|
||||||
|
{ id: 'kitchen_error', label: 'Λάθος κουζίνας', color: '#dc2626' },
|
||||||
|
{ id: 'dropped', label: 'Έπεσε', color: '#d97706' },
|
||||||
|
{ id: 'expired', label: 'Έληξε', color: '#7c3aed' },
|
||||||
|
{ id: 'other', label: 'Άλλο', color: '#6b7280' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log form ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LogForm({ products, onLog, isPending }) {
|
||||||
|
const [productId, setProductId] = useState('')
|
||||||
|
const [qty, setQty] = useState('1')
|
||||||
|
const [reason, setReason] = useState('kitchen_error')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
const canLog = productId && parseFloat(qty) > 0
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!canLog) return
|
||||||
|
onLog({
|
||||||
|
product_id: Number(productId),
|
||||||
|
quantity: parseFloat(qty),
|
||||||
|
reason,
|
||||||
|
reason_notes: notes.trim() || null,
|
||||||
|
})
|
||||||
|
setProductId('')
|
||||||
|
setQty('1')
|
||||||
|
setReason('kitchen_error')
|
||||||
|
setNotes('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 14, padding: '16px 20px', background: '#fafafa' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 14 }}>
|
||||||
|
Καταγραφή Απόβλητου
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Προϊόν *</label>
|
||||||
|
<select style={inputStyle} value={productId} onChange={e => setProductId(e.target.value)} autoFocus>
|
||||||
|
<option value="">— Επιλέξτε προϊόν —</option>
|
||||||
|
{products.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}{p.category_name ? ` (${p.category_name})` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Ποσότητα *</label>
|
||||||
|
<input type="number" step="0.5" min="0.1"
|
||||||
|
style={inputStyle} value={qty} onChange={e => setQty(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>Αιτία</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{REASONS.map(r => (
|
||||||
|
<button key={r.id} type="button" onClick={() => setReason(r.id)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px', borderRadius: 20, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1.5px solid ${reason === r.id ? r.color : '#e5e7eb'}`,
|
||||||
|
background: reason === r.id ? r.color : 'white',
|
||||||
|
color: reason === r.id ? 'white' : '#6b7280',
|
||||||
|
}}>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
|
||||||
|
Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span>
|
||||||
|
</label>
|
||||||
|
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder="π.χ. Ο πελάτης επέστρεψε ελαττωματικό…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={submit} disabled={!canLog || isPending}
|
||||||
|
style={{
|
||||||
|
padding: '9px 22px', borderRadius: 9, border: 'none', fontSize: 13.5, fontWeight: 700, cursor: canLog ? 'pointer' : 'default',
|
||||||
|
background: canLog ? '#111827' : '#e5e7eb', color: canLog ? 'white' : '#9ca3af',
|
||||||
|
}}>
|
||||||
|
{isPending ? 'Καταγραφή…' : '+ Καταγραφή Απόβλητου'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Waste entry row ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WasteRow({ entry, canDelete, onDelete }) {
|
||||||
|
const reasonCfg = REASONS.find(r => r.id === entry.reason) || { label: entry.reason, color: '#6b7280' }
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{entry.product_name}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>×{entry.quantity}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 7px', borderRadius: 99, background: `${reasonCfg.color}18`, color: reasonCfg.color }}>
|
||||||
|
{reasonCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 2 }}>
|
||||||
|
{fmtDateTime(entry.logged_at)} · {entry.logged_by_name}
|
||||||
|
{entry.reason_notes && <span style={{ color: '#6b7280', marginLeft: 8 }}>— {entry.reason_notes}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
{entry.total_cost != null ? (
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#dc2626' }}>{fmt(entry.total_cost)}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: '#d1d5db' }}>χωρίς κόστος</div>
|
||||||
|
)}
|
||||||
|
{entry.unit_cost_snapshot != null && (
|
||||||
|
<div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(entry.unit_cost_snapshot)}/τεμ.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canDelete && (
|
||||||
|
<button onClick={() => { if (window.confirm('Διαγραφή καταχώρησης;')) onDelete(entry.id) }}
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', color: '#ef4444', fontSize: 12, cursor: 'pointer', flexShrink: 0 }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function WastePage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const [histFrom, setHistFrom] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
const [histTo, setHistTo] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
|
|
||||||
|
const { data: productsData = [] } = useQuery({
|
||||||
|
queryKey: ['meta-products'],
|
||||||
|
queryFn: () => client.get('/api/reports/meta/products').then(r => r.data.products),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: bdData } = useQuery({
|
||||||
|
queryKey: ['business-day'],
|
||||||
|
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
const activeDayId = bdData?.id ?? null
|
||||||
|
|
||||||
|
// Today's entries — scoped to active business day (or all if no active day)
|
||||||
|
const { data: todayData } = useQuery({
|
||||||
|
queryKey: ['waste-today', activeDayId],
|
||||||
|
queryFn: () => client.get('/api/waste/', { params: activeDayId ? { business_day_id: activeDayId } : {} }).then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Historical entries
|
||||||
|
const { data: histData, refetch: refetchHist } = useQuery({
|
||||||
|
queryKey: ['waste-history', histFrom, histTo],
|
||||||
|
queryFn: () => client.get('/api/waste/', { params: { from: histFrom + 'T00:00:00', to: histTo + 'T23:59:59' } }).then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: showHistory,
|
||||||
|
})
|
||||||
|
|
||||||
|
const todayEntries = todayData?.entries || []
|
||||||
|
const histEntries = histData?.entries || []
|
||||||
|
|
||||||
|
const todayCost = todayEntries.reduce((s, e) => s + (e.total_cost || 0), 0)
|
||||||
|
const todayCount = todayEntries.length
|
||||||
|
|
||||||
|
const logWaste = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/waste/', body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['waste-today'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['waste-history'] })
|
||||||
|
toast.success('Καταγράφηκε')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteWaste = useMutation({
|
||||||
|
mutationFn: (id) => client.delete(`/api/waste/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['waste-today'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['waste-history'] })
|
||||||
|
toast.success('Διαγράφηκε')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Αποβλήτα / Φθορές</h1>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||||
|
Σήμερα: {todayCount} καταχωρήσεις
|
||||||
|
{todayCost > 0 && <span style={{ color: '#dc2626', fontWeight: 600 }}> · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* Log form */}
|
||||||
|
<LogForm products={productsData} onLog={body => logWaste.mutate(body)} isPending={logWaste.isPending} />
|
||||||
|
|
||||||
|
{/* Today's entries */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
|
||||||
|
{activeDayId ? 'Σήμερα' : 'Τελευταίες καταχωρήσεις'}
|
||||||
|
</div>
|
||||||
|
{todayEntries.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '20px 0' }}>Καμία καταχώρηση ακόμα.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{todayEntries.map(e => (
|
||||||
|
<WasteRow key={e.id} entry={e} canDelete={!!activeDayId} onDelete={id => deleteWaste.mutate(id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History toggle */}
|
||||||
|
<div style={{ borderTop: '1px solid #f0f0ef', paddingTop: 12 }}>
|
||||||
|
<button onClick={() => setShowHistory(s => !s)}
|
||||||
|
style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
|
||||||
|
{showHistory ? '▾ Απόκρυψη ιστορικού' : '▸ Εμφάνιση ιστορικού'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Από</label>
|
||||||
|
<input type="date" value={histFrom} onChange={e => setHistFrom(e.target.value)}
|
||||||
|
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Έως</label>
|
||||||
|
<input type="date" value={histTo} onChange={e => setHistTo(e.target.value)}
|
||||||
|
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetchHist()}
|
||||||
|
style={{ padding: '5px 14px', borderRadius: 7, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||||||
|
Αναζήτηση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{histEntries.length === 0 ? (
|
||||||
|
<div style={{ color: '#d1d5db', fontSize: 13, padding: '12px 0' }}>Δεν βρέθηκαν καταχωρήσεις.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{histEntries.map(e => (
|
||||||
|
<WasteRow key={e.id} entry={e} canDelete={false} onDelete={() => {}} />
|
||||||
|
))}
|
||||||
|
<div style={{ fontSize: 12.5, color: '#6b7280', fontWeight: 600, paddingLeft: 4 }}>
|
||||||
|
{histEntries.length} καταχωρήσεις · εκτιμώμενο κόστος{' '}
|
||||||
|
<span style={{ color: '#dc2626' }}>
|
||||||
|
{histEntries.reduce((s, e) => s + (e.total_cost || 0), 0).toLocaleString('el-GR', { minimumFractionDigits: 2 })} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import TableAnalytics from './restaurant/TableAnalytics'
|
|||||||
import PrinterHistory from './operations/PrinterHistory'
|
import PrinterHistory from './operations/PrinterHistory'
|
||||||
import PrinterHealthLog from './operations/PrinterHealthLog'
|
import PrinterHealthLog from './operations/PrinterHealthLog'
|
||||||
import CancellationsLog from './operations/CancellationsLog'
|
import CancellationsLog from './operations/CancellationsLog'
|
||||||
|
import DiscountsLog from './operations/DiscountsLog'
|
||||||
|
|
||||||
const PARENT_TABS = [
|
const PARENT_TABS = [
|
||||||
{
|
{
|
||||||
@@ -58,6 +59,7 @@ const PARENT_TABS = [
|
|||||||
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
|
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
|
||||||
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
|
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
|
||||||
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
|
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
|
||||||
|
{ id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
191
manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx
Normal file
191
manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag } from 'lucide-react'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
|
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
|
||||||
|
import StatCard from '../shared/StatCard'
|
||||||
|
import EmptyState from '../shared/EmptyState'
|
||||||
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
export default function DiscountsLog() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
|
const [from, setFrom] = useState(monthAgo())
|
||||||
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [waiterF, setWaiterF] = useState('all')
|
||||||
|
|
||||||
|
const { data: waitersData } = useQuery({
|
||||||
|
queryKey: ['meta-waiters'],
|
||||||
|
queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const { data: bdData } = useQuery({
|
||||||
|
queryKey: ['business-days-list'],
|
||||||
|
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
|
...(waiterF !== 'all' ? { applied_by: waiterF } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['discounts', mode, from, to, businessDayId, waiterF],
|
||||||
|
queryFn: () => client.get('/api/reports/discounts', { params: queryParams }).then(r => r.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const waiterOptions = [
|
||||||
|
{ value: 'all', label: 'Όλοι' },
|
||||||
|
...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name }))),
|
||||||
|
]
|
||||||
|
const bdOptions = [
|
||||||
|
{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' },
|
||||||
|
...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` }))),
|
||||||
|
]
|
||||||
|
|
||||||
|
const discounts = data?.discounts || []
|
||||||
|
const byWaiter = data?.by_waiter || []
|
||||||
|
const totalValue = data?.total_discount_value ?? 0
|
||||||
|
const orderCount = data?.order_count ?? 0
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={7} /></div>
|
||||||
|
if (isError) return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<FilterBar>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<StatCard label="Σύνολο Εκπτώσεων" value={fmtEUR(totalValue)} icon={Tag} color="amber" />
|
||||||
|
<StatCard label="Παραγγελίες με Έκπτωση" value={fmtNum(orderCount)} color="slate" />
|
||||||
|
<StatCard label="Αριθμός Εκπτώσεων" value={fmtNum(discounts.length)} color="slate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By waiter summary */}
|
||||||
|
{byWaiter.length > 1 && (
|
||||||
|
<Panel title="Ανά Σερβιτόρο" padded={false}>
|
||||||
|
<DataTable>
|
||||||
|
<THead>
|
||||||
|
<TH>Σερβιτόρος</TH>
|
||||||
|
<TH align="right">Αριθμός</TH>
|
||||||
|
<TH align="right">Σύνολο</TH>
|
||||||
|
<TH align="right">Μέσος / Έκπτωση</TH>
|
||||||
|
</THead>
|
||||||
|
<tbody>
|
||||||
|
{byWaiter.map(w => (
|
||||||
|
<TR key={w.waiter_id} striped>
|
||||||
|
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
|
||||||
|
<TD mono align="right">{fmtNum(w.count)}</TD>
|
||||||
|
<TD mono align="right" className="font-semibold text-amber-700">{fmtEUR(w.total_value)}</TD>
|
||||||
|
<TD mono align="right">{fmtEUR(w.count > 0 ? w.total_value / w.count : 0)}</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</DataTable>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full log */}
|
||||||
|
<Panel
|
||||||
|
title="Αρχείο Εκπτώσεων"
|
||||||
|
subtitle={`${discounts.length} εγγραφές — μόνο ανάγνωση`}
|
||||||
|
padded={false}
|
||||||
|
>
|
||||||
|
{discounts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title="Δεν βρέθηκαν εκπτώσεις"
|
||||||
|
description="Δεν υπάρχουν εκπτώσεις για την επιλεγμένη περίοδο."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable>
|
||||||
|
<THead>
|
||||||
|
<TH>Ημ/νία</TH>
|
||||||
|
<TH>Παραγγελία</TH>
|
||||||
|
<TH>Σερβιτόρος</TH>
|
||||||
|
<TH align="right">Τύπος</TH>
|
||||||
|
<TH align="right">Τιμή πριν</TH>
|
||||||
|
<TH align="right">Ποσό Έκπτωσης</TH>
|
||||||
|
<TH>Αιτία</TH>
|
||||||
|
</THead>
|
||||||
|
<tbody>
|
||||||
|
{discounts.map(d => {
|
||||||
|
const typeLabel = d.discount_type === 'percent'
|
||||||
|
? `${d.discount_value}%`
|
||||||
|
: fmtEUR(d.discount_value)
|
||||||
|
const isItemLevel = d.item_id != null
|
||||||
|
return (
|
||||||
|
<TR key={d.id} striped>
|
||||||
|
<TD mono className="text-slate-500">{fmtDateTime(d.applied_at)}</TD>
|
||||||
|
<TD>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
#{d.order_id}
|
||||||
|
{d.table_name && <span className="text-slate-400 font-normal ml-1">· {d.table_name}</span>}
|
||||||
|
</span>
|
||||||
|
{isItemLevel && (
|
||||||
|
<span className="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">ΑΝΤΙΚ.</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
<TD><WaiterAvatar name={d.applied_by_name} id={d.applied_by_id} /></TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${d.discount_type === 'percent' ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right" className="text-slate-500">
|
||||||
|
{d.order_total_before != null ? fmtEUR(d.order_total_before) : '—'}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right" className="font-semibold text-amber-700">
|
||||||
|
− {fmtEUR(d.discount_amount)}
|
||||||
|
</TD>
|
||||||
|
<TD className="text-slate-500 text-[12px]">
|
||||||
|
{d.reason || <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
</TR>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{discounts.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<TR>
|
||||||
|
<TD colSpan={5} className="font-semibold text-slate-700">Σύνολο</TD>
|
||||||
|
<TD mono align="right" className="font-bold text-amber-700">− {fmtEUR(totalValue)}</TD>
|
||||||
|
<TD />
|
||||||
|
</TR>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</DataTable>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import StatCard from '../shared/StatCard'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
|
import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -37,6 +37,7 @@ const PRINT_TYPES = [
|
|||||||
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
|
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
|
||||||
const [printType, setPrintType] = useState('quick')
|
const [printType, setPrintType] = useState('quick')
|
||||||
const [targetPrinter, setTargetPrinter] = useState('browser')
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [itemBreakdown, setItemBreakdown] = useState(false)
|
||||||
const [printing, setPrinting] = useState(false)
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
const printerOptions = [
|
const printerOptions = [
|
||||||
@@ -53,23 +54,25 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleThermalPrint() {
|
async function handleThermalPrint() {
|
||||||
// Map frontend print types to backend modes
|
|
||||||
// quick/orders → simple, detailed → extensive
|
|
||||||
const mode = printType === 'detailed' ? 'extensive' : 'simple'
|
|
||||||
const printerTargetId = printerF === 'all' ? 0 : parseInt(printerF, 10)
|
const printerTargetId = printerF === 'all' ? 0 : parseInt(printerF, 10)
|
||||||
|
|
||||||
const fromDt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
|
const body = {
|
||||||
const toDt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
|
printer_target_id: printerTargetId,
|
||||||
|
printer_id: parseInt(targetPrinter, 10),
|
||||||
|
mode: printType,
|
||||||
|
item_breakdown: itemBreakdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
body.business_day_id = parseInt(queryParams.business_day_id, 10)
|
||||||
|
} else {
|
||||||
|
body.from_dt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
|
||||||
|
body.to_dt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
|
||||||
|
}
|
||||||
|
|
||||||
setPrinting(true)
|
setPrinting(true)
|
||||||
try {
|
try {
|
||||||
await client.post('/api/reports/print/printer', {
|
await client.post('/api/reports/print/printer', body)
|
||||||
printer_target_id: printerTargetId,
|
|
||||||
printer_id: parseInt(targetPrinter, 10),
|
|
||||||
mode,
|
|
||||||
from_dt: fromDt,
|
|
||||||
to_dt: toDt,
|
|
||||||
})
|
|
||||||
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -80,105 +83,207 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowserPrint() {
|
function handleBrowserPrint() {
|
||||||
const win = window.open('', '_blank', 'width=800,height=700')
|
const win = window.open('', '_blank', 'width=820,height=750')
|
||||||
if (!win) { onClose(); return }
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
const styles = `
|
// ── Group logs by printer ──────────────────────────────────────────────
|
||||||
<style>
|
// For "all printers" we produce one section per printer.
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
// For a single selected printer the single section is the same format.
|
||||||
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; }
|
const printerMap = {}
|
||||||
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
|
for (const j of logs) {
|
||||||
h2 { font-size: 13px; font-weight: bold; margin: 14px 0 6px; }
|
const key = j.printer_name
|
||||||
.row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dotted #ccc; }
|
if (!printerMap[key]) {
|
||||||
.row:last-child { border-bottom: none; }
|
printerMap[key] = { name: key, printJobs: 0, orderIds: new Set(), totalItems: 0, totalValue: 0, orders: [] }
|
||||||
.section { margin-bottom: 16px; }
|
}
|
||||||
.order-block { border: 1px solid #ccc; border-radius: 4px; padding: 8px; margin-bottom: 8px; }
|
const g = printerMap[key]
|
||||||
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 4px; }
|
g.printJobs += 1
|
||||||
.item-row { padding-left: 12px; display: flex; justify-content: space-between; font-size: 11px; color: #333; }
|
g.orderIds.add(j.order_id)
|
||||||
.failed { color: #c00; }
|
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
|
||||||
.top-items li { padding: 2px 0; }
|
g.totalItems += itemQty
|
||||||
@media print { body { padding: 0; } }
|
if (j.order_total != null) g.totalValue += j.order_total
|
||||||
</style>
|
g.orders.push(j)
|
||||||
`
|
}
|
||||||
|
|
||||||
|
// De-dup order totals per printer group (same order_id → count once)
|
||||||
|
for (const g of Object.values(printerMap)) {
|
||||||
|
const seen = {}
|
||||||
|
g.totalValue = 0
|
||||||
|
for (const j of g.orders) {
|
||||||
|
if (j.success && j.order_total != null && !seen[j.order_id]) {
|
||||||
|
seen[j.order_id] = true
|
||||||
|
g.totalValue += j.order_total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = Object.values(printerMap)
|
||||||
const filterLabel = printerF === 'all'
|
const filterLabel = printerF === 'all'
|
||||||
? 'Όλοι οι Εκτυπωτές'
|
? 'Όλοι οι Εκτυπωτές'
|
||||||
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
|
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
|
||||||
|
|
||||||
let body = ''
|
// ── Period label ───────────────────────────────────────────────────────
|
||||||
|
let periodRow = ''
|
||||||
if (printType === 'quick') {
|
if (queryParams.business_day_id) {
|
||||||
const topItems = Object.entries(stats.itemCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${stats.periodLabel}</span></div>`
|
||||||
const topWaiters = Object.entries(stats.waiterCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
||||||
const avgItems = stats.total > 0 ? (stats.totalItemQty / stats.total).toFixed(1) : '—'
|
|
||||||
|
|
||||||
body = `
|
|
||||||
<h1>Σύνοψη Εκτυπωτή</h1>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
|
||||||
<div class="row"><span>Περίοδος</span><span>${stats.periodLabel}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Στατιστικά</h2>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Συνολικές Εκτυπώσεις</span><span>${fmtNum(stats.total)}</span></div>
|
|
||||||
<div class="row"><span>Αποτυχημένες Εκτυπώσεις</span><span class="${stats.failed > 0 ? 'failed' : ''}">${fmtNum(stats.failed)}</span></div>
|
|
||||||
${stats.totalAmount != null ? `<div class="row"><span>Συνολικό Ποσό Παραγγελιών</span><span>${fmtEUR(stats.totalAmount)}</span></div>` : ''}
|
|
||||||
<div class="row"><span>Μέσος Αριθμός Ειδών ανά Παραγγελία</span><span>${avgItems}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Top 3 Πιο Εκτυπωμένα Είδη</h2>
|
|
||||||
<div class="section">
|
|
||||||
<ol class="top-items" style="padding-left:16px">
|
|
||||||
${topItems.length ? topItems.map(([name, qty], i) => `<li>${i + 1}. ${name} — ${qty} τεμ.</li>`).join('') : '<li>—</li>'}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
${topWaiters.length ? `
|
|
||||||
<h2>Εκτυπώσεις ανά Σερβιτόρο</h2>
|
|
||||||
<div class="section">
|
|
||||||
${topWaiters.map(([name, cnt]) => `<div class="row"><span>${name}</span><span>${cnt}</span></div>`).join('')}
|
|
||||||
</div>` : ''}
|
|
||||||
`
|
|
||||||
} else if (printType === 'orders') {
|
|
||||||
body = `
|
|
||||||
<h1>Λίστα Παραγγελιών</h1>
|
|
||||||
<div class="section">
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
|
||||||
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
|
|
||||||
</div>
|
|
||||||
<h2>Παραγγελίες</h2>
|
|
||||||
${logs.map(j => `
|
|
||||||
<div class="order-block ${!j.success ? 'failed' : ''}">
|
|
||||||
<div class="order-header">
|
|
||||||
<span>#${j.order_id} · ${j.table || '—'}</span>
|
|
||||||
<span>${fmtDateTime(j.printed_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
|
|
||||||
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
|
|
||||||
${j.order_total != null ? `<div class="row"><span>Σύνολο</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
`
|
|
||||||
} else {
|
} else {
|
||||||
body = `
|
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
<h1>Πλήρης Ανάλυση Εκτυπώσεων</h1>
|
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
<div class="section">
|
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
|
||||||
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
|
|
||||||
</div>
|
|
||||||
${logs.map(j => `
|
|
||||||
<div class="order-block ${!j.success ? 'failed' : ''}">
|
|
||||||
<div class="order-header">
|
|
||||||
<span>#${j.order_id} · ${j.table || '—'}</span>
|
|
||||||
<span>${fmtDateTime(j.printed_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
|
|
||||||
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
|
|
||||||
${(j.items || []).map(i => `<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`).join('')}
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${body}</body></html>`)
|
// ── Titles per mode ────────────────────────────────────────────────────
|
||||||
|
const titles = {
|
||||||
|
quick: 'Γρήγορη Ανάλυση Εκτυπώσεων',
|
||||||
|
orders: 'Ανάλυση Εκτυπώσεων με Παραγγελίες',
|
||||||
|
detailed: 'Αναλυτικό Ιστορικό Εκτυπώσεων',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render one printer block ───────────────────────────────────────────
|
||||||
|
function renderPrinterBlock(g) {
|
||||||
|
const orderCount = g.orderIds.size
|
||||||
|
const statsBlock = `
|
||||||
|
<div class="printer-header">
|
||||||
|
<span>Εκτυπωτής</span><span>${g.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(g.printJobs)}</span></div>
|
||||||
|
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(orderCount)}</span></div>
|
||||||
|
<div class="row"><span>Είδη</span><span>${fmtNum(g.totalItems)}</span></div>
|
||||||
|
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(g.totalValue)}</span></div>
|
||||||
|
`
|
||||||
|
|
||||||
|
if (printType === 'quick') {
|
||||||
|
return `<div class="printer-block">${statsBlock}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printType === 'orders') {
|
||||||
|
// Unique orders only
|
||||||
|
const seenOrders = {}
|
||||||
|
const orderLines = g.orders
|
||||||
|
.filter(j => j.success)
|
||||||
|
.filter(j => { if (seenOrders[j.order_id]) return false; seenOrders[j.order_id] = true; return true })
|
||||||
|
.map(j => {
|
||||||
|
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
|
||||||
|
return `<div class="order-line">
|
||||||
|
<span class="order-id">#${j.order_id} · ${j.table || '—'}</span>
|
||||||
|
<span class="order-meta">${itemQty} είδη</span>
|
||||||
|
<span class="order-value">${j.order_total != null ? fmtEUR(j.order_total) : '—'}</span>
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${orderLines}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailed — full log, each entry is a print job
|
||||||
|
const jobBlocks = g.orders.map(j => {
|
||||||
|
const result = j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'
|
||||||
|
const itemRows = (j.items || []).map(i =>
|
||||||
|
`<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`
|
||||||
|
).join('')
|
||||||
|
return `
|
||||||
|
<div class="job-block ${!j.success ? 'failed' : ''}">
|
||||||
|
<div class="job-header">
|
||||||
|
<span>#${j.order_id} · ${j.table || '—'}</span>
|
||||||
|
<span>${fmtDateTime(j.printed_at)} · ${result}</span>
|
||||||
|
</div>
|
||||||
|
${itemRows}
|
||||||
|
${j.order_total != null ? `<div class="job-total"><span>Σύνολο Παραγγελίας</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${jobBlocks}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary footer (multi-printer only) ───────────────────────────────
|
||||||
|
function renderSummary() {
|
||||||
|
if (groups.length <= 1) return ''
|
||||||
|
const totJobs = groups.reduce((s, g) => s + g.printJobs, 0)
|
||||||
|
const totOrders = groups.reduce((s, g) => s + g.orderIds.size, 0)
|
||||||
|
const totItems = groups.reduce((s, g) => s + g.totalItems, 0)
|
||||||
|
const totValue = groups.reduce((s, g) => s + g.totalValue, 0)
|
||||||
|
return `
|
||||||
|
<div class="summary-block">
|
||||||
|
<div class="summary-title">Σύνολο Όλων των Εκτυπωτών</div>
|
||||||
|
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(totJobs)}</span></div>
|
||||||
|
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(totOrders)}</span></div>
|
||||||
|
<div class="row"><span>Είδη</span><span>${fmtNum(totItems)}</span></div>
|
||||||
|
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(totValue)}</span></div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product breakdown (optional) ───────────────────────────────────────
|
||||||
|
function renderItemBreakdown() {
|
||||||
|
if (!itemBreakdown) return ''
|
||||||
|
// Merge item counts across all printer groups
|
||||||
|
const combined = {}
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const j of g.orders) {
|
||||||
|
if (!j.success) continue
|
||||||
|
for (const i of (j.items || [])) {
|
||||||
|
if (!combined[i.name]) combined[i.name] = { qty: 0 }
|
||||||
|
combined[i.name].qty += i.quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Object.keys(combined).length) return ''
|
||||||
|
const sorted = Object.entries(combined).sort((a, b) => b[1].qty - a[1].qty)
|
||||||
|
return `
|
||||||
|
<div class="breakdown-block">
|
||||||
|
<div class="breakdown-title">Ανάλυση Προϊόντων</div>
|
||||||
|
${sorted.map(([name, d]) => `
|
||||||
|
<div class="breakdown-row">
|
||||||
|
<span class="breakdown-name">${name}</span>
|
||||||
|
<span class="breakdown-qty">${d.qty}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
|
||||||
|
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
|
||||||
|
.meta { margin-bottom: 16px; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 3px 0; }
|
||||||
|
.divider { border-top: 1px dashed #999; margin: 6px 0; }
|
||||||
|
.printer-block { border: 1px solid #ccc; border-radius: 4px; padding: 10px; margin-bottom: 14px; }
|
||||||
|
.printer-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 13px; margin-bottom: 4px; }
|
||||||
|
.value-row { font-weight: bold; }
|
||||||
|
.order-line { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; }
|
||||||
|
.order-id { flex: 1; }
|
||||||
|
.order-meta { color: #666; min-width: 60px; text-align: right; }
|
||||||
|
.order-value { min-width: 70px; text-align: right; font-weight: bold; }
|
||||||
|
.job-block { margin-bottom: 8px; padding: 6px 0; border-bottom: 1px dotted #ddd; }
|
||||||
|
.job-block:last-child { border-bottom: none; }
|
||||||
|
.job-block.failed { color: #b00; }
|
||||||
|
.job-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 11px; margin-bottom: 3px; }
|
||||||
|
.item-row { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; color: #333; }
|
||||||
|
.job-total { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; font-weight: bold; margin-top: 3px; }
|
||||||
|
.summary-block { border: 2px solid #000; border-radius: 4px; padding: 10px; margin-top: 8px; }
|
||||||
|
.summary-title { font-weight: bold; font-size: 13px; margin-bottom: 6px; text-align: center; letter-spacing: 0.04em; }
|
||||||
|
.breakdown-block { border: 1px solid #888; border-radius: 4px; padding: 10px; margin-top: 14px; }
|
||||||
|
.breakdown-title { font-weight: bold; font-size: 13px; margin-bottom: 8px; letter-spacing: 0.04em; }
|
||||||
|
.breakdown-row { display: flex; justify-content: space-between; padding: 2px 0; border-bottom: 1px dotted #ccc; font-size: 12px; }
|
||||||
|
.breakdown-row:last-child { border-bottom: none; }
|
||||||
|
.breakdown-name { flex: 1; }
|
||||||
|
.breakdown-qty { font-weight: bold; min-width: 40px; text-align: right; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<h1>${titles[printType] || 'Αναφορά Εκτυπωτή'}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
|
||||||
|
${periodRow}
|
||||||
|
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
|
||||||
|
</div>
|
||||||
|
${groups.map(renderPrinterBlock).join('')}
|
||||||
|
${renderSummary()}
|
||||||
|
${renderItemBreakdown()}
|
||||||
|
`
|
||||||
|
|
||||||
|
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${htmlBody}</body></html>`)
|
||||||
win.document.close()
|
win.document.close()
|
||||||
win.focus()
|
win.focus()
|
||||||
win.print()
|
win.print()
|
||||||
@@ -221,6 +326,19 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 transition hover:border-slate-300 hover:bg-slate-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={itemBreakdown}
|
||||||
|
onChange={e => setItemBreakdown(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-sky-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-800">Ανάλυση Προϊόντων</div>
|
||||||
|
<div className="text-[12px] text-slate-500">Προσθέτει σύνολο τεμαχίων ανά προϊόν στο τέλος</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -289,16 +407,16 @@ export default function PrinterHistory() {
|
|||||||
const logs = data?.logs || []
|
const logs = data?.logs || []
|
||||||
const total = data?.total || 0
|
const total = data?.total || 0
|
||||||
const failed = data?.failed || 0
|
const failed = data?.failed || 0
|
||||||
|
// total_amount comes pre-computed from the backend (unique orders, no double-counting reprints)
|
||||||
|
const totalAmount = data?.total_amount ?? null
|
||||||
|
|
||||||
// Derived stats for StatCards + Print modal
|
// Derived stats for StatCards + Print modal
|
||||||
const itemCounts = {}
|
const itemCounts = {}
|
||||||
const waiterCounts = {}
|
const waiterCounts = {}
|
||||||
let totalItemQty = 0
|
let totalItemQty = 0
|
||||||
let totalAmount = null
|
|
||||||
|
|
||||||
logs.forEach(l => {
|
logs.forEach(l => {
|
||||||
if (l.waiter) waiterCounts[l.waiter] = (waiterCounts[l.waiter] || 0) + 1
|
if (l.waiter) waiterCounts[l.waiter] = (waiterCounts[l.waiter] || 0) + 1
|
||||||
if (l.order_total != null) totalAmount = (totalAmount || 0) + l.order_total
|
|
||||||
;(l.items || []).forEach(i => {
|
;(l.items || []).forEach(i => {
|
||||||
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity
|
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity
|
||||||
totalItemQty += i.quantity
|
totalItemQty += i.quantity
|
||||||
@@ -308,7 +426,7 @@ export default function PrinterHistory() {
|
|||||||
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
|
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
|
||||||
const periodLabel = mode === 'workday'
|
const periodLabel = mode === 'workday'
|
||||||
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
||||||
: `${from} → ${to}`
|
: `${fmtDateStr(from)} → ${fmtDateStr(to)}`
|
||||||
|
|
||||||
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
|
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,333 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
|
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } from '../shared/reportDesignTokens'
|
||||||
import { fmtEUR, fmtNum, CHART_PALETTE } from '../shared/reportDesignTokens'
|
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── SVG donut chart (inline, for print window) ───────────────────────────────
|
||||||
|
|
||||||
|
function svgDonut(categories, size = 200) {
|
||||||
|
const total = categories.reduce((s, c) => s + c.revenue, 0)
|
||||||
|
if (!total) return ''
|
||||||
|
const cx = size / 2, cy = size / 2, R = size * 0.42, r = size * 0.22
|
||||||
|
let angle = -Math.PI / 2
|
||||||
|
const slices = categories.map((c, i) => {
|
||||||
|
const frac = c.revenue / total
|
||||||
|
const a0 = angle, a1 = angle + frac * 2 * Math.PI
|
||||||
|
angle = a1
|
||||||
|
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0)
|
||||||
|
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1)
|
||||||
|
const xi0 = cx + r * Math.cos(a0), yi0 = cy + r * Math.sin(a0)
|
||||||
|
const xi1 = cx + r * Math.cos(a1), yi1 = cy + r * Math.sin(a1)
|
||||||
|
const large = frac > 0.5 ? 1 : 0
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const d = `M${xi0},${yi0} L${x0},${y0} A${R},${R} 0 ${large},1 ${x1},${y1} L${xi1},${yi1} A${r},${r} 0 ${large},0 ${xi0},${yi0} Z`
|
||||||
|
return `<path d="${d}" fill="${color}" />`
|
||||||
|
}).join('')
|
||||||
|
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">${slices}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Print modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRINT_TYPES = [
|
||||||
|
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατηγορίες με τεμάχια, έσοδα και ποσοστό' },
|
||||||
|
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Κατηγορίες με γράφημα πίτας και αναλυτική εικόνα' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function CategoryPrintModal({ onClose, categories, allProducts, soldProducts, printers, queryParams, periodLabel }) {
|
||||||
|
const [printType, setPrintType] = useState('smart')
|
||||||
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
const printerOptions = [
|
||||||
|
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
|
||||||
|
...printers.map(p => ({ value: String(p.id), label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
function periodRow() {
|
||||||
|
if (queryParams.business_day_id)
|
||||||
|
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
|
||||||
|
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
|
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
|
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserPrint() {
|
||||||
|
const win = window.open('', '_blank', 'width=820,height=800')
|
||||||
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
|
const totalRev = categories.reduce((s, c) => s + c.revenue, 0)
|
||||||
|
const totalQty = categories.reduce((s, c) => s + c.units_sold, 0)
|
||||||
|
|
||||||
|
let body = ''
|
||||||
|
if (printType === 'smart') {
|
||||||
|
const rows = categories.map(c => {
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : '0'
|
||||||
|
return `<tr>
|
||||||
|
<td>${c.category_name}</td>
|
||||||
|
<td class="num">${fmtNum(c.units_sold)}</td>
|
||||||
|
<td class="num">${fmtEUR(c.revenue)}</td>
|
||||||
|
<td class="num">${pctRev}%</td>
|
||||||
|
<td class="num">${pctQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
body = `<table>
|
||||||
|
<thead><tr><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
|
||||||
|
</table>`
|
||||||
|
} else {
|
||||||
|
// Build sold-product lookup: product_id → {qty_sold, revenue, category_id}
|
||||||
|
const soldMap = {}
|
||||||
|
for (const p of soldProducts) soldMap[p.product_id] = p
|
||||||
|
// Group allProducts by category_id
|
||||||
|
const productsByCategory = {}
|
||||||
|
for (const ap of allProducts) {
|
||||||
|
if (!productsByCategory[ap.category_id]) productsByCategory[ap.category_id] = []
|
||||||
|
productsByCategory[ap.category_id].push(ap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const donut = svgDonut(categories)
|
||||||
|
const legend = categories.map((c, i) => {
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
|
||||||
|
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-name">${c.category_name}</span><span class="legend-val">${pctRev}% έσ. / ${pctQty}% τεμ.</span></div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
// Full category blocks with per-product breakdown
|
||||||
|
const catBlocks = categories.map((c, i) => {
|
||||||
|
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
|
||||||
|
const pctRev = c.pct_rev ?? c.pct ?? 0
|
||||||
|
const pctQtyOfTotal = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
|
||||||
|
|
||||||
|
const catProducts = (productsByCategory[c.category_id] || [])
|
||||||
|
.map(ap => {
|
||||||
|
const sold = soldMap[ap.id]
|
||||||
|
return { name: ap.name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
|
||||||
|
})
|
||||||
|
.filter(p => p.qty > 0)
|
||||||
|
.sort((a, b) => b.qty - a.qty)
|
||||||
|
const catTotalQty = catProducts.reduce((s, p) => s + p.qty, 0)
|
||||||
|
const catTotalRev = catProducts.reduce((s, p) => s + p.revenue, 0)
|
||||||
|
|
||||||
|
const productRows = catProducts.map(p => {
|
||||||
|
const pctProdRev = catTotalRev ? (p.revenue / catTotalRev * 100).toFixed(1) : '0'
|
||||||
|
const pctProdQty = catTotalQty ? (p.qty / catTotalQty * 100).toFixed(1) : '0'
|
||||||
|
return `<tr>
|
||||||
|
<td class="prod-name">${p.name}</td>
|
||||||
|
<td class="num">${fmtNum(p.qty)}</td>
|
||||||
|
<td class="num">${fmtEUR(p.revenue)}</td>
|
||||||
|
<td class="num">${pctProdRev}%</td>
|
||||||
|
<td class="num">${pctProdQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="cat-full-block" style="border-left: 4px solid ${color}">
|
||||||
|
<div class="cat-full-header">
|
||||||
|
<span class="cat-full-name">${c.category_name}</span>
|
||||||
|
<span class="cat-full-rev">${fmtEUR(c.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat-full-sub">${fmtNum(c.units_sold)} τεμ. · ${c.product_count} προϊόντα · ${pctRev}% εσόδων · ${pctQtyOfTotal}% τεμ.</div>
|
||||||
|
${catProducts.length ? `
|
||||||
|
<table class="prod-table">
|
||||||
|
<colgroup><col/><col style="width:52px"/><col style="width:64px"/><col style="width:48px"/><col style="width:48px"/></colgroup>
|
||||||
|
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${productRows}</tbody>
|
||||||
|
</table>` : '<div class="no-sales">Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο</div>'}
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
body = `
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="donut-wrap">${donut}</div>
|
||||||
|
<div class="legend">${legend}</div>
|
||||||
|
</div>
|
||||||
|
${catBlocks}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = printType === 'smart' ? 'Σύνοψη Κατηγοριών' : 'Πλήρης Ανάλυση Κατηγοριών'
|
||||||
|
const styles = `<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
|
||||||
|
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
|
||||||
|
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 12px; font-family: 'Courier New', monospace; }
|
||||||
|
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
|
||||||
|
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
|
||||||
|
.num { text-align: right; font-weight: bold; }
|
||||||
|
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
|
||||||
|
.chart-row { display: flex; align-items: flex-start; gap: 24px; margin: 16px 0; }
|
||||||
|
.donut-wrap { flex-shrink: 0; }
|
||||||
|
.legend { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||||||
|
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||||
|
.legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
.legend-name { flex: 1; }
|
||||||
|
.legend-val { font-weight: bold; min-width: 40px; text-align: right; }
|
||||||
|
.cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||||
|
.cat-card { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 4px; }
|
||||||
|
.cat-name { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; color: #475569; margin-bottom: 4px; }
|
||||||
|
.cat-rev { font-size: 20px; font-weight: bold; font-family: 'Courier New', monospace; margin-bottom: 2px; }
|
||||||
|
.cat-sub { font-size: 10px; color: #64748b; }
|
||||||
|
.cat-full-block { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 14px; }
|
||||||
|
.cat-full-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
|
||||||
|
.cat-full-name { font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.cat-full-rev { font-size: 13px; font-weight: bold; font-family: 'Courier New', monospace; color: #334155; }
|
||||||
|
.cat-full-sub { font-size: 10px; color: #64748b; margin-bottom: 8px; }
|
||||||
|
.prod-table { width: 100%; border-collapse: collapse; font-size: 10px; margin-top: 6px; table-layout: fixed; }
|
||||||
|
.prod-table th, .prod-table td { padding: 2px 4px; border-bottom: 1px dotted #e2e8f0; font-size: 10px; }
|
||||||
|
.prod-table thead th { border-bottom: 1px solid #cbd5e1; font-size: 9px; text-transform: uppercase; color: #64748b; font-weight: bold; }
|
||||||
|
.prod-table th.num, .prod-table td.num { text-align: right; font-weight: bold; }
|
||||||
|
.prod-table th:first-child, .prod-table td:first-child { text-align: left; }
|
||||||
|
.prod-name { color: #1e293b; }
|
||||||
|
.no-sales { font-size: 10px; color: #94a3b8; font-style: italic; margin-top: 4px; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
${periodRow()}
|
||||||
|
<div class="row"><span>Κατηγορίες</span><span>${categories.length}</span></div>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</body></html>`)
|
||||||
|
win.document.close()
|
||||||
|
win.focus()
|
||||||
|
win.print()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThermalPrint() {
|
||||||
|
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
body.business_day_id = parseInt(queryParams.business_day_id, 10)
|
||||||
|
} else {
|
||||||
|
body.from_dt = queryParams.from
|
||||||
|
body.to_dt = queryParams.to
|
||||||
|
}
|
||||||
|
setPrinting(true)
|
||||||
|
try {
|
||||||
|
await client.post('/api/reports/print/categories', body)
|
||||||
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
|
||||||
|
} finally {
|
||||||
|
setPrinting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
if (targetPrinter === 'browser') handleBrowserPrint()
|
||||||
|
else handleThermalPrint()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Κατηγοριών</h2>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRINT_TYPES.map(pt => (
|
||||||
|
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
|
||||||
|
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-800">{pt.label}</div>
|
||||||
|
<div className="text-[12px] text-slate-500">{pt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
|
||||||
|
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
|
||||||
|
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
|
||||||
|
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
|
||||||
|
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CategoryPerformance() {
|
export default function CategoryPerformance() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||||
|
const [pieMetric, setPieMetric] = useState('revenue')
|
||||||
|
|
||||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
||||||
|
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
|
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['category-performance', from, to],
|
queryKey: ['category-performance', mode, from, to, businessDayId],
|
||||||
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
|
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: productsData } = useQuery({
|
||||||
|
queryKey: ['product-performance-for-cat', mode, from, to, businessDayId],
|
||||||
|
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
|
||||||
|
const printers = printersData?.printers || []
|
||||||
|
const allProducts = allProductsData?.products || []
|
||||||
|
const soldProducts = productsData?.products || []
|
||||||
const categories = data?.categories || []
|
const categories = data?.categories || []
|
||||||
const pieData = categories.map((c, i) => ({
|
const anyHasCost = categories.some(c => (c.total_cost || 0) > 0)
|
||||||
|
const pieCategories = pieMetric === 'profit'
|
||||||
|
? categories.filter(c => (c.trackable_profit || 0) > 0)
|
||||||
|
: categories
|
||||||
|
const pieData = pieCategories.map((c, i) => ({
|
||||||
name: c.category_name,
|
name: c.category_name,
|
||||||
value: c.revenue,
|
value: pieMetric === 'profit' ? c.trackable_profit : c.revenue,
|
||||||
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
|
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const periodLabel = mode === 'workday'
|
||||||
|
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
||||||
|
: `${fmtDateStr(from)} → ${fmtDateStr(to)}`
|
||||||
|
|
||||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
@@ -41,9 +338,20 @@ export default function CategoryPerformance() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar>
|
<FilterBar right={
|
||||||
|
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
|
||||||
|
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
|
||||||
|
</button>
|
||||||
|
}>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -53,7 +361,18 @@ export default function CategoryPerformance() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-4">
|
||||||
<div className="col-span-5">
|
<div className="col-span-5">
|
||||||
<Panel title="Κατανομή Εσόδων">
|
<Panel title={pieMetric === 'profit' ? 'Κατανομή Κέρδους' : 'Κατανομή Εσόδων'} right={
|
||||||
|
anyHasCost ? (
|
||||||
|
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
||||||
|
{[['revenue', 'Έσοδα'], ['profit', 'Κέρδος']].map(([m, label]) => (
|
||||||
|
<button key={m} onClick={() => setPieMetric(m)} className={`rounded px-2 py-1 font-medium transition ${pieMetric === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}>
|
||||||
|
{pieData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-[12px] text-slate-400">Δεν υπάρχουν δεδομένα κέρδους</div>
|
||||||
|
) : (
|
||||||
<div style={{ height: 280 }}>
|
<div style={{ height: 280 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -64,10 +383,15 @@ export default function CategoryPerformance() {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-7 grid grid-cols-2 gap-4">
|
<div className="col-span-7 grid grid-cols-2 gap-4">
|
||||||
{categories.map((c, i) => (
|
{categories.map((c, i) => {
|
||||||
|
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
|
||||||
|
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
|
return (
|
||||||
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
|
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
|
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
|
||||||
@@ -76,32 +400,70 @@ export default function CategoryPerformance() {
|
|||||||
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
|
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
|
||||||
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
|
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
|
||||||
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
|
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
|
||||||
<span className="font-mono">{c.pct}%</span>
|
<span className="font-mono">{c.pct_rev ?? c.pct}%</span>
|
||||||
|
</div>
|
||||||
|
{(c.trackable_profit || 0) > 0 && (
|
||||||
|
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
|
||||||
|
<span className="text-[11px] font-medium text-green-700">{fmtEUR(c.trackable_profit)} κέρδος</span>
|
||||||
|
{margin && <span className="text-[10px] text-slate-400">{margin}{c.has_gap && ' ⚠'}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Κατηγορίες" padded={false}>
|
<Panel title="Κατηγορίες" padded={false}>
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<THead><TH>Κατηγορία</TH><TH align="right">Προϊόντα</TH><TH align="right">Τεμάχια</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">% Συνόλου</TH></THead>
|
<THead>
|
||||||
|
<TH>Κατηγορία</TH>
|
||||||
|
<TH align="right">Προϊόντα</TH>
|
||||||
|
<TH align="right">Τεμάχια</TH>
|
||||||
|
<TH align="right">Έσοδα</TH>
|
||||||
|
{anyHasCost && <TH align="right">Κόστος</TH>}
|
||||||
|
{anyHasCost && <TH align="right">Κέρδος</TH>}
|
||||||
|
<TH align="right">% Εσόδων</TH>
|
||||||
|
{anyHasCost && <TH align="right">% Κέρδους</TH>}
|
||||||
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categories.map((c, i) => (
|
{categories.map((c, i) => {
|
||||||
|
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
|
||||||
|
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
|
return (
|
||||||
<TR key={c.category_id} striped>
|
<TR key={c.category_id} striped>
|
||||||
<TD>
|
<TD><span className="inline-flex items-center gap-2 font-medium text-slate-900"><span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />{c.category_name}</span></TD>
|
||||||
<span className="inline-flex items-center gap-2 font-medium text-slate-900">
|
|
||||||
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
|
|
||||||
{c.category_name}
|
|
||||||
</span>
|
|
||||||
</TD>
|
|
||||||
<TD mono align="right">{c.product_count}</TD>
|
<TD mono align="right">{c.product_count}</TD>
|
||||||
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
|
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
|
||||||
<TD mono align="right">{c.pct}%</TD>
|
{anyHasCost && (
|
||||||
|
<TD mono align="right" className="text-slate-600">
|
||||||
|
{(c.total_cost || 0) > 0 ? fmtEUR(c.total_cost) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{(c.trackable_profit || 0) > 0 ? (
|
||||||
|
<span className="text-green-700">
|
||||||
|
{fmtEUR(c.trackable_profit)}
|
||||||
|
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin}){c.has_gap && ' ⚠'}</span>}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
|
<TD mono align="right">{c.pct_rev ?? c.pct}%</TD>
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{(c.pct_profit || 0) > 0
|
||||||
|
? <span className="text-green-700">{c.pct_profit}%</span>
|
||||||
|
: <span className="text-slate-300">—</span>
|
||||||
|
}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
</TR>
|
</TR>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -109,6 +471,18 @@ export default function CategoryPerformance() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<CategoryPrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
categories={categories}
|
||||||
|
allProducts={allProducts}
|
||||||
|
soldProducts={soldProducts}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,314 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Legend } from 'recharts'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtEUR, fmtNum, fmtDate, CHART_PALETTE } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── SVG bar chart rendered as inline HTML ────────────────────────────────────
|
||||||
|
// Used inside the browser print window (no Recharts available there).
|
||||||
|
|
||||||
|
function svgBarChart(items, valueKey, labelKey, colorFn, width = 680, barH = 18, gap = 6) {
|
||||||
|
const top5 = [...items].slice(0, 5)
|
||||||
|
const maxVal = Math.max(1, ...top5.map(d => d[valueKey]))
|
||||||
|
const labelW = 160
|
||||||
|
const numW = 70
|
||||||
|
const barAreaW = width - labelW - numW - 16
|
||||||
|
const h = top5.length * (barH + gap) + gap
|
||||||
|
|
||||||
|
const bars = top5.map((d, i) => {
|
||||||
|
const y = gap + i * (barH + gap)
|
||||||
|
const bw = Math.round((d[valueKey] / maxVal) * barAreaW)
|
||||||
|
const color = colorFn(i)
|
||||||
|
return `
|
||||||
|
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d[labelKey]}</text>
|
||||||
|
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${color}" />
|
||||||
|
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${d[valueKey]}</text>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<svg width="${width}" height="${h}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Print modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRINT_TYPES = [
|
||||||
|
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Μόνο προϊόντα με πωλήσεις — σε σειρά ποσότητας' },
|
||||||
|
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Όλα τα προϊόντα, ακόμα και με 0 πωλήσεις, με αξία + γραφήματα' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ProductPrintModal({ onClose, products, allProducts, printers, queryParams, periodLabel }) {
|
||||||
|
const [printType, setPrintType] = useState('smart')
|
||||||
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
const printerOptions = [
|
||||||
|
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
|
||||||
|
...printers.map(p => ({ value: String(p.id), label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
function periodRow() {
|
||||||
|
if (queryParams.business_day_id)
|
||||||
|
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
|
||||||
|
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
|
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
|
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserPrint() {
|
||||||
|
const win = window.open('', '_blank', 'width=820,height=800')
|
||||||
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
|
const soldMap = {}
|
||||||
|
for (const p of products) soldMap[p.product_id] = p
|
||||||
|
|
||||||
|
const totalQty = products.reduce((s, p) => s + p.qty_sold, 0)
|
||||||
|
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
||||||
|
|
||||||
|
let tableSection = ''
|
||||||
|
if (printType === 'smart') {
|
||||||
|
const sorted = [...products].sort((a, b) => b.qty_sold - a.qty_sold)
|
||||||
|
const rows = sorted.map(p => {
|
||||||
|
const pctRev = totalRev ? (p.revenue / totalRev * 100).toFixed(1) : '0'
|
||||||
|
const pctQty = totalQty ? (p.qty_sold / totalQty * 100).toFixed(1) : '0'
|
||||||
|
return `<tr>
|
||||||
|
<td>${p.product_name}</td>
|
||||||
|
<td class="num">${fmtNum(p.qty_sold)}</td>
|
||||||
|
<td class="num">${fmtEUR(p.revenue)}</td>
|
||||||
|
<td class="num">${pctRev}%</td>
|
||||||
|
<td class="num">${pctQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
tableSection = `<table>
|
||||||
|
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
|
||||||
|
</table>`
|
||||||
|
} else {
|
||||||
|
// Full: merge all products, including 0-sold
|
||||||
|
const merged = allProducts.map(ap => {
|
||||||
|
const sold = soldMap[ap.id]
|
||||||
|
return { name: ap.name, category: ap.category_name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
|
||||||
|
}).sort((a, b) => b.qty - a.qty)
|
||||||
|
|
||||||
|
// Top-5 bar charts (qty and revenue)
|
||||||
|
const top5qty = merged.filter(p => p.qty > 0).slice(0, 5)
|
||||||
|
const top5rev = merged.filter(p => p.revenue > 0).slice(0, 5).sort((a, b) => b.revenue - a.revenue)
|
||||||
|
|
||||||
|
const chartQty = svgBarChart(top5qty, 'qty', 'name', i => CHART_PALETTE[i % CHART_PALETTE.length])
|
||||||
|
const maxRev = Math.max(1, ...top5rev.map(d => d.revenue))
|
||||||
|
const labelW = 160, barH = 18, gap = 6, barAreaW = 680 - labelW - 90 - 16
|
||||||
|
const revBars = top5rev.map((d, i) => {
|
||||||
|
const y = gap + i * (barH + gap)
|
||||||
|
const bw = Math.round((d.revenue / maxRev) * barAreaW)
|
||||||
|
return `
|
||||||
|
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d.name}</text>
|
||||||
|
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${CHART_PALETTE[i % CHART_PALETTE.length]}" />
|
||||||
|
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${fmtEUR(d.revenue)}</text>`
|
||||||
|
}).join('')
|
||||||
|
const revH = top5rev.length * (barH + gap) + gap
|
||||||
|
const chartRevSvg = `<svg width="680" height="${revH}" xmlns="http://www.w3.org/2000/svg">${revBars}</svg>`
|
||||||
|
|
||||||
|
const rows = merged.map(p => {
|
||||||
|
const pctRev = totalRev && p.revenue ? (p.revenue / totalRev * 100).toFixed(1) : '—'
|
||||||
|
const pctQty = totalQty && p.qty ? (p.qty / totalQty * 100).toFixed(1) : '—'
|
||||||
|
return `<tr class="${p.qty === 0 ? 'zero' : ''}">
|
||||||
|
<td>${p.name}</td>
|
||||||
|
<td class="cat">${p.category}</td>
|
||||||
|
<td class="num">${fmtNum(p.qty)}</td>
|
||||||
|
<td class="num">${fmtEUR(p.revenue)}</td>
|
||||||
|
<td class="num">${pctRev}%</td>
|
||||||
|
<td class="num">${pctQty}%</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
tableSection = `
|
||||||
|
<div class="chart-section">
|
||||||
|
<div class="chart-title">Top 5 σε Τεμάχια</div>
|
||||||
|
${chartQty}
|
||||||
|
</div>
|
||||||
|
<div class="chart-section">
|
||||||
|
<div class="chart-title">Top 5 σε Έσοδα</div>
|
||||||
|
${chartRevSvg}
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Προϊόν</th><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot><tr><td colspan="2">Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = printType === 'smart' ? 'Έξυπνη Σύνοψη Προϊόντων' : 'Πλήρης Ανάλυση Προϊόντων'
|
||||||
|
const styles = `<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
|
||||||
|
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
|
||||||
|
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||||
|
.chart-section { margin: 16px 0; }
|
||||||
|
.chart-title { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 16px; font-family: 'Courier New', monospace; }
|
||||||
|
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
|
||||||
|
.num { text-align: right; font-weight: bold; }
|
||||||
|
.cat { color: #555; font-size: 10px; }
|
||||||
|
.zero { color: #aaa; }
|
||||||
|
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
${periodRow()}
|
||||||
|
<div class="row"><span>Προϊόντα με πωλήσεις</span><span>${products.length}</span></div>
|
||||||
|
${printType === 'full' ? `<div class="row"><span>Σύνολο ενεργών προϊόντων</span><span>${allProducts.length}</span></div>` : ''}
|
||||||
|
</div>
|
||||||
|
${tableSection}
|
||||||
|
</body></html>`)
|
||||||
|
win.document.close()
|
||||||
|
win.focus()
|
||||||
|
win.print()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThermalPrint() {
|
||||||
|
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
body.business_day_id = parseInt(queryParams.business_day_id, 10)
|
||||||
|
} else {
|
||||||
|
body.from_dt = queryParams.from
|
||||||
|
body.to_dt = queryParams.to
|
||||||
|
}
|
||||||
|
setPrinting(true)
|
||||||
|
try {
|
||||||
|
await client.post('/api/reports/print/products', body)
|
||||||
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
|
||||||
|
} finally {
|
||||||
|
setPrinting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
if (targetPrinter === 'browser') handleBrowserPrint()
|
||||||
|
else handleThermalPrint()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Προϊόντων</h2>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRINT_TYPES.map(pt => (
|
||||||
|
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
|
||||||
|
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-800">{pt.label}</div>
|
||||||
|
<div className="text-[12px] text-slate-500">{pt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
|
||||||
|
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
{targetPrinter !== 'browser' && printType === 'full' && (
|
||||||
|
<p className="mt-1.5 text-[11px] text-amber-600">Η Πλήρης Ανάλυση σε θερμικό θα εκτυπώσει μόνο τη λίστα χωρίς γραφήματα.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
|
||||||
|
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
|
||||||
|
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
|
||||||
|
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ProductPerformance() {
|
export default function ProductPerformance() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
const [catF, setCatF] = useState('all')
|
const [catF, setCatF] = useState('all')
|
||||||
const [chartMode, setChartMode] = useState('revenue')
|
const [chartMode, setChartMode] = useState('revenue')
|
||||||
|
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||||
|
// 'revenue' | 'units' | 'profit' | 'dual'
|
||||||
|
|
||||||
|
|
||||||
|
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
||||||
|
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
|
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
from: from + 'T00:00:00',
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
to: to + 'T23:59:59',
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
...(catF !== 'all' ? { category_id: catF } : {}),
|
...(catF !== 'all' ? { category_id: catF } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['product-performance', from, to, catF],
|
queryKey: ['product-performance', mode, from, to, businessDayId, catF],
|
||||||
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
|
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
|
||||||
|
const allProducts = allProductsData?.products || []
|
||||||
|
const printers = printersData?.printers || []
|
||||||
|
|
||||||
const products = data?.products || []
|
const products = data?.products || []
|
||||||
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
||||||
|
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
|
||||||
|
const anyHasCost = products.some(p => (p.total_cost || 0) > 0)
|
||||||
|
|
||||||
const top10 = products.slice(0, 10).map((p, i) => ({
|
const top10 = [...products]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (chartMode === 'units') return b.qty_sold - a.qty_sold
|
||||||
|
if (chartMode === 'profit') return (b.trackable_profit || 0) - (a.trackable_profit || 0)
|
||||||
|
// revenue and dual: sort by revenue
|
||||||
|
return b.revenue - a.revenue
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((p, i) => ({
|
||||||
name: p.product_name,
|
name: p.product_name,
|
||||||
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
|
revenue: p.revenue,
|
||||||
|
profit: p.has_gap ? null : (p.trackable_profit || 0),
|
||||||
|
units: p.qty_sold,
|
||||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
const periodLabel = mode === 'workday'
|
||||||
|
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
||||||
|
: `${fmtDateStr(from)} → ${fmtDateStr(to)}`
|
||||||
|
|
||||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
@@ -53,74 +321,219 @@ export default function ProductPerformance() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar right={
|
<FilterBar right={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
|
||||||
|
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
|
||||||
|
</button>
|
||||||
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
|
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
|
||||||
|
</div>
|
||||||
}>
|
}>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel
|
{(() => {
|
||||||
title="Top 10 Προϊόντα"
|
const chartModes = [
|
||||||
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
|
['revenue', 'Έσοδα'],
|
||||||
right={
|
['units', 'Τεμάχια'],
|
||||||
|
...(anyHasCost ? [['profit', 'Κέρδος'], ['dual', 'Έσοδα vs Κέρδος']] : []),
|
||||||
|
]
|
||||||
|
const subtitleMap = { revenue: 'έσοδα', units: 'τεμάχια', profit: 'κέρδος', dual: 'έσοδα vs κέρδος' }
|
||||||
|
const isDual = chartMode === 'dual'
|
||||||
|
return (
|
||||||
|
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${subtitleMap[chartMode] || 'έσοδα'}`} right={
|
||||||
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
||||||
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
|
{chartModes.map(([m, label]) => (
|
||||||
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
|
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
{top10.length === 0 ? (
|
{top10.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: 280 }}>
|
<div style={{ height: isDual ? 320 : 280 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
|
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: isDual ? 16 : 4, left: 4 }}>
|
||||||
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
|
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={v => chartMode === 'units' ? String(v) : '€' + v} />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
|
||||||
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
|
<Tooltip content={<ChartTooltip formatter={(v, name) => {
|
||||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
|
if (chartMode === 'units') return v + ' τεμ.'
|
||||||
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
|
return fmtEUR(v)
|
||||||
|
}} />} cursor={{ fill: '#f1f5f9' }} />
|
||||||
|
{isDual && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
|
||||||
|
{isDual ? (
|
||||||
|
<>
|
||||||
|
<Bar dataKey="revenue" name="Έσοδα" fill="#60a5fa" radius={[0, 0, 0, 0]} barSize={10} />
|
||||||
|
<Bar dataKey="profit" name="Κέρδος" fill="#34d399" radius={[0, 3, 3, 0]} barSize={10} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Bar dataKey={chartMode === 'units' ? 'units' : chartMode === 'profit' ? 'profit' : 'revenue'}
|
||||||
|
radius={[0, 3, 3, 0]} barSize={16}>
|
||||||
|
{top10.map((d, i) => (
|
||||||
|
<Cell key={i} fill={chartMode === 'profit' ? '#34d399' : d.color} />
|
||||||
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
)}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
|
{(() => {
|
||||||
|
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
|
||||||
|
const totalCost = products.reduce((s, p) => s + (p.total_cost || 0), 0)
|
||||||
|
const hasGap = products.some(p => p.has_gap)
|
||||||
|
const overallMargin = totalRev > 0 && !hasGap
|
||||||
|
? ((totalProfit / totalRev) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Όλα τα Προϊόντα"
|
||||||
|
subtitle={`${products.length} προϊόντα με πωλήσεις`}
|
||||||
|
padded={false}
|
||||||
|
>
|
||||||
|
{hasGap && (
|
||||||
|
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||||||
|
<span className="shrink-0 font-bold">⚠</span>
|
||||||
|
<span>
|
||||||
|
Μερικά προϊόντα δεν έχουν κόστος — το κέρδος που φαίνεται είναι μερικό.
|
||||||
|
Ορισμένα έσοδα <strong>{fmtEUR(totalUncostedRev)}</strong> δεν συμπεριλαμβάνονται στο κέρδος.{' '}
|
||||||
|
<a href="/management?tab=products" className="underline hover:text-amber-900">Ορισμός κόστους προϊόντων →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<THead>
|
<THead>
|
||||||
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
|
<TH>Προϊόν</TH>
|
||||||
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
|
<TH align="right">Τεμάχια</TH>
|
||||||
|
<TH align="right">Έσοδα</TH>
|
||||||
|
{anyHasCost && <TH align="right">Κόστος</TH>}
|
||||||
|
<TH align="right">Κέρδος</TH>
|
||||||
|
<TH align="right">Απόβλητα</TH>
|
||||||
|
<TH align="right">% Έσόδων</TH>
|
||||||
|
{anyHasCost && <TH align="right">% Κέρδους</TH>}
|
||||||
|
<TH align="right">Παραγγελίες</TH>
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{products.map(p => {
|
{products.map(p => {
|
||||||
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
const pctRev = totalRev ? (p.revenue / totalRev * 100) : 0
|
||||||
|
const pctProfit = totalProfit > 0 && !p.has_gap
|
||||||
|
? (p.trackable_profit / totalProfit * 100)
|
||||||
|
: null
|
||||||
|
const margin = p.revenue > 0 && !p.has_gap && (p.total_cost || 0) > 0
|
||||||
|
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<TR key={p.product_id} striped>
|
<TR key={p.product_id} striped>
|
||||||
<TD className="font-medium text-slate-900">{p.product_name}</TD>
|
<TD className="font-medium text-slate-900">{p.product_name}</TD>
|
||||||
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
|
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
|
||||||
<TD mono align="right">{pct.toFixed(1)}%</TD>
|
{anyHasCost && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{(p.total_cost || 0) > 0
|
||||||
|
? <span className="text-slate-600">{fmtEUR(p.total_cost)}</span>
|
||||||
|
: <span className="text-slate-300">—</span>
|
||||||
|
}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
|
<TD mono align="right">
|
||||||
|
{p.has_gap ? (
|
||||||
|
<span className="text-amber-500 text-[11px]">⚠ χωρίς κόστος</span>
|
||||||
|
) : (p.total_cost || 0) === 0 ? (
|
||||||
|
<span className="text-slate-300">—</span>
|
||||||
|
) : (
|
||||||
|
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
|
||||||
|
{fmtEUR(p.trackable_profit)}
|
||||||
|
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{p.waste_qty > 0 ? (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
{p.waste_qty}
|
||||||
|
{p.waste_cost > 0 && <span className="text-[10px] text-amber-400 ml-1">({fmtEUR(p.waste_cost)})</span>}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right">{pctRev.toFixed(1)}%</TD>
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{pctProfit != null
|
||||||
|
? <span className="text-green-700">{pctProfit.toFixed(1)}%</span>
|
||||||
|
: <span className="text-slate-300">—</span>
|
||||||
|
}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
||||||
</TR>
|
</TR>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{products.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<TR>
|
||||||
|
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
|
||||||
|
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right" className="font-bold text-slate-600">{fmtEUR(totalCost)}</TD>
|
||||||
|
)}
|
||||||
|
<TD mono align="right" className="font-bold">
|
||||||
|
{hasGap ? (
|
||||||
|
<span className="text-amber-600">{fmtEUR(totalProfit)}{' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
|
||||||
|
) : totalProfit > 0 ? (
|
||||||
|
<span className="text-green-700">
|
||||||
|
{fmtEUR(totalProfit)}
|
||||||
|
{overallMargin && <span className="ml-1 text-[10px] text-slate-400">({overallMargin})</span>}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
<TD />
|
||||||
|
<TD mono align="right" className="font-bold">100%</TD>
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right" className="font-bold text-green-700">
|
||||||
|
{totalProfit > 0 && !hasGap ? '100%' : '—'}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
|
<TD />
|
||||||
|
</TR>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<ProductPrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
products={products}
|
||||||
|
allProducts={allProducts}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
|||||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import { fmtEUR, fmtNum, fmtDate } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, fmtDate, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -27,9 +27,11 @@ export default function RevenueTrends() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const trends = data?.trends || []
|
const trends = data?.trends || []
|
||||||
|
const anyHasProfit = trends.some(d => (d.profit || 0) > 0)
|
||||||
const chartData = trends.map(d => ({
|
const chartData = trends.map(d => ({
|
||||||
label: gran === 'monthly' ? d.date : fmtDate(d.date),
|
label: gran === 'monthly' ? d.date : fmtDate(d.date),
|
||||||
revenue: d.revenue,
|
revenue: d.revenue,
|
||||||
|
profit: (d.profit || 0) > 0 ? d.profit : undefined,
|
||||||
rolling7: d.rolling7,
|
rolling7: d.rolling7,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export default function RevenueTrends() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${from} → ${to}`}>
|
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
|
||||||
{trends.length === 0 ? (
|
{trends.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
@@ -69,6 +71,7 @@ export default function RevenueTrends() {
|
|||||||
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
|
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
|
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
|
||||||
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
|
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
|
||||||
|
{anyHasProfit && <Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} connectNulls={false} />}
|
||||||
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
|
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -83,6 +86,7 @@ export default function RevenueTrends() {
|
|||||||
<TH>Περίοδος</TH>
|
<TH>Περίοδος</TH>
|
||||||
<TH align="right">Παραγγελίες</TH>
|
<TH align="right">Παραγγελίες</TH>
|
||||||
<TH align="right">Έσοδα</TH>
|
<TH align="right">Έσοδα</TH>
|
||||||
|
{anyHasProfit && <TH align="right">Κέρδος</TH>}
|
||||||
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
|
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -91,6 +95,17 @@ export default function RevenueTrends() {
|
|||||||
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
|
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
|
||||||
<TD mono align="right">{fmtNum(d.orders)}</TD>
|
<TD mono align="right">{fmtNum(d.orders)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
|
||||||
|
{anyHasProfit && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{(d.profit || 0) > 0
|
||||||
|
? <span className="text-green-700">
|
||||||
|
{fmtEUR(d.profit)}
|
||||||
|
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500">⚠</span>}
|
||||||
|
</span>
|
||||||
|
: <span className="text-slate-300">—</span>
|
||||||
|
}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
|
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
|
||||||
</TR>
|
</TR>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,31 +1,222 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtEUR, fmtNum, fmtMinutes } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, fmtMinutes, fmtDate, fmtTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
// ─── Print modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRINT_TYPES = [
|
||||||
|
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατάταξη τραπεζιών με παραγγελίες, μέση διάρκεια και έσοδα' },
|
||||||
|
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Ίδιο, με επιπλέον μέσο έσοδο ανά επίσκεψη' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function TablePrintModal({ onClose, tables, printers, queryParams, periodLabel }) {
|
||||||
|
const [printType, setPrintType] = useState('smart')
|
||||||
|
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
const printerOptions = [
|
||||||
|
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
|
||||||
|
...printers.map(p => ({ value: String(p.id), label: p.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleBrowserPrint() {
|
||||||
|
const win = window.open('', '_blank', 'width=820,height=750')
|
||||||
|
if (!win) { onClose(); return }
|
||||||
|
|
||||||
|
let periodRow = ''
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
|
||||||
|
} else {
|
||||||
|
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
|
||||||
|
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
|
||||||
|
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
|
||||||
|
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOrders = tables.reduce((s, t) => s + t.order_count, 0)
|
||||||
|
const totalRev = tables.reduce((s, t) => s + t.revenue, 0)
|
||||||
|
|
||||||
|
const rows = tables.map(t => {
|
||||||
|
const avgRev = t.order_count ? t.revenue / t.order_count : 0
|
||||||
|
const dur = t.avg_duration_minutes != null ? fmtMinutes(t.avg_duration_minutes) : '—'
|
||||||
|
if (printType === 'smart') {
|
||||||
|
return `<tr>
|
||||||
|
<td>${t.table_name}</td>
|
||||||
|
<td class="num">${fmtNum(t.order_count)}</td>
|
||||||
|
<td class="num">${dur}</td>
|
||||||
|
<td class="num">${fmtEUR(t.revenue)}</td>
|
||||||
|
</tr>`
|
||||||
|
}
|
||||||
|
return `<tr>
|
||||||
|
<td>${t.table_name}</td>
|
||||||
|
<td class="num">${fmtNum(t.order_count)}</td>
|
||||||
|
<td class="num">${dur}</td>
|
||||||
|
<td class="num">${fmtEUR(avgRev)}</td>
|
||||||
|
<td class="num">${fmtEUR(t.revenue)}</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const headers = printType === 'smart'
|
||||||
|
? `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Έσοδα</th>`
|
||||||
|
: `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Μ. / Επίσκεψη</th><th class="num">Έσοδα</th>`
|
||||||
|
const footCols = printType === 'smart'
|
||||||
|
? `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
|
||||||
|
: `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
|
||||||
|
|
||||||
|
const title = printType === 'smart' ? 'Σύνοψη Τραπεζιών' : 'Πλήρης Ανάλυση Τραπεζιών'
|
||||||
|
const styles = `<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; max-width: 800px; }
|
||||||
|
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
|
||||||
|
.meta { margin-bottom: 16px; }
|
||||||
|
.row { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 8px; }
|
||||||
|
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
|
||||||
|
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
|
||||||
|
.num { text-align: right; font-weight: bold; }
|
||||||
|
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>`
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
${periodRow}
|
||||||
|
<div class="row"><span>Τραπέζια</span><span>${tables.length}</span></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>${headers}</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot><tr>${footCols}</tr></tfoot>
|
||||||
|
</table>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
win.document.write(html)
|
||||||
|
win.document.close()
|
||||||
|
win.focus()
|
||||||
|
win.print()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThermalPrint() {
|
||||||
|
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
|
||||||
|
if (queryParams.business_day_id) {
|
||||||
|
body.business_day_id = parseInt(queryParams.business_day_id, 10)
|
||||||
|
} else {
|
||||||
|
body.from_dt = queryParams.from
|
||||||
|
body.to_dt = queryParams.to
|
||||||
|
}
|
||||||
|
setPrinting(true)
|
||||||
|
try {
|
||||||
|
await client.post('/api/reports/print/tables', body)
|
||||||
|
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
|
||||||
|
} finally {
|
||||||
|
setPrinting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
if (targetPrinter === 'browser') handleBrowserPrint()
|
||||||
|
else handleThermalPrint()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Τραπεζιών</h2>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRINT_TYPES.map(pt => (
|
||||||
|
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
|
||||||
|
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-800">{pt.label}</div>
|
||||||
|
<div className="text-[12px] text-slate-500">{pt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
|
||||||
|
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
|
||||||
|
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">
|
||||||
|
Ακύρωση
|
||||||
|
</button>
|
||||||
|
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
|
||||||
|
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TableAnalytics() {
|
export default function TableAnalytics() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||||
|
|
||||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
||||||
|
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['table-analytics', from, to],
|
queryKey: ['table-analytics', mode, from, to, businessDayId],
|
||||||
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
|
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
|
||||||
|
const printers = printersData?.printers || []
|
||||||
const tables = data?.tables || []
|
const tables = data?.tables || []
|
||||||
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
|
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
|
||||||
|
|
||||||
|
const periodLabel = mode === 'workday'
|
||||||
|
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
|
||||||
|
: `${fmtDateStr(from)} → ${fmtDateStr(to)}`
|
||||||
|
|
||||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
@@ -37,10 +228,26 @@ export default function TableAnalytics() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar right={
|
<FilterBar right={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPrintModal(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Εκτύπωση Σύνοψης
|
||||||
|
</button>
|
||||||
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
|
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
|
||||||
|
</div>
|
||||||
}>
|
}>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -94,6 +301,16 @@ export default function TableAnalytics() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPrintModal && (
|
||||||
|
<TablePrintModal
|
||||||
|
onClose={() => setShowPrintModal(false)}
|
||||||
|
tables={tables}
|
||||||
|
printers={printers}
|
||||||
|
queryParams={queryParams}
|
||||||
|
periodLabel={periodLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||||
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset } from 'lucide-react'
|
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset, PiggyBank, ShoppingBag } from 'lucide-react'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar } from '../shared/FilterBar'
|
import { FilterBar } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
@@ -94,6 +94,22 @@ export default function Today() {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
|
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
|
||||||
</div>
|
</div>
|
||||||
|
{bd.trackable_profit > 0 && (
|
||||||
|
<div className="mt-4 flex items-center gap-3 border-t border-sky-100 pt-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-[0.08em] text-slate-400">Μεικτό Κέρδος</div>
|
||||||
|
<div className="font-mono text-[22px] font-semibold tabular-nums text-green-700">
|
||||||
|
{fmtEUR(bd.trackable_profit)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bd.revenue > 0 && (
|
||||||
|
<div className="rounded-md bg-green-50 px-2.5 py-1 text-[13px] font-semibold text-green-700 ring-1 ring-inset ring-green-200">
|
||||||
|
{((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin
|
||||||
|
{bd.has_gap && <span className="ml-1 text-[10px] text-amber-500">⚠ μερικό</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-7 grid grid-cols-3 gap-4">
|
<div className="col-span-7 grid grid-cols-3 gap-4">
|
||||||
@@ -104,6 +120,17 @@ export default function Today() {
|
|||||||
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
|
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
|
||||||
)}
|
)}
|
||||||
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
|
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
|
||||||
|
{bd.total_cost > 0 && (
|
||||||
|
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
|
||||||
|
)}
|
||||||
|
{bd.trackable_profit > 0 && (
|
||||||
|
<StatCard
|
||||||
|
label="Μεικτό Κέρδος"
|
||||||
|
value={fmtEUR(bd.trackable_profit)}
|
||||||
|
sub={bd.has_gap ? '⚠ μερικό κέρδος' : `${((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin`}
|
||||||
|
icon={PiggyBank}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,11 +165,16 @@ export default function Today() {
|
|||||||
<DataTable>
|
<DataTable>
|
||||||
<THead>
|
<THead>
|
||||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||||
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right">Κέρδος</TH><TH>Κατάσταση</TH>
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.slice(0, 30).map(o => {
|
{orders.slice(0, 30).map(o => {
|
||||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
|
||||||
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
const costedItems = activeItems.filter(i => i.unit_cost != null)
|
||||||
|
const orderProfit = costedItems.length > 0
|
||||||
|
? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
|
||||||
|
: null
|
||||||
const isCancelled = o.status === 'cancelled'
|
const isCancelled = o.status === 'cancelled'
|
||||||
return (
|
return (
|
||||||
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
||||||
@@ -152,6 +184,12 @@ export default function Today() {
|
|||||||
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
||||||
<TD mono align="right">{(o.items || []).length}</TD>
|
<TD mono align="right">{(o.items || []).length}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{orderProfit != null
|
||||||
|
? <span className={orderProfit >= 0 ? 'text-green-700' : 'text-red-600'}>{fmtEUR(orderProfit)}</span>
|
||||||
|
: <span className="text-slate-300">—</span>
|
||||||
|
}
|
||||||
|
</TD>
|
||||||
<TD><StatusBadge status={o.status} pulse /></TD>
|
<TD><StatusBadge status={o.status} pulse /></TD>
|
||||||
</TR>
|
</TR>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
|
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import StatCard from '../shared/StatCard'
|
import StatCard from '../shared/StatCard'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import { fmtNum } from '../shared/reportDesignTokens'
|
import { fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -17,17 +17,26 @@ const DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυ
|
|||||||
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
|
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
|
||||||
|
|
||||||
export default function TrafficAnalytics() {
|
export default function TrafficAnalytics() {
|
||||||
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
|
||||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||||
|
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['traffic', from, to],
|
queryKey: ['traffic', mode, from, to, businessDayId],
|
||||||
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
|
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
|
||||||
|
|
||||||
const byHour = useMemo(() => {
|
const byHour = useMemo(() => {
|
||||||
const hourMap = {}
|
const hourMap = {}
|
||||||
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
|
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
|
||||||
@@ -36,12 +45,6 @@ export default function TrafficAnalytics() {
|
|||||||
|
|
||||||
const byWeekday = data?.by_weekday || []
|
const byWeekday = data?.by_weekday || []
|
||||||
|
|
||||||
// Build heatmap matrix [dow][hourIndex] = orders
|
|
||||||
const matrix = useMemo(() => {
|
|
||||||
// Backend only gives aggregated by_hour, so build a simple row from that
|
|
||||||
return DOWS.map(() => HOURS.map(() => 0))
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
const chartData = byHour.map(h => ({
|
const chartData = byHour.map(h => ({
|
||||||
hour: `${String(h.hour).padStart(2, '0')}:00`,
|
hour: `${String(h.hour).padStart(2, '0')}:00`,
|
||||||
orders: h.orders,
|
orders: h.orders,
|
||||||
@@ -65,8 +68,15 @@ export default function TrafficAnalytics() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
|
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||||
|
{mode === 'workday' ? (
|
||||||
|
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -96,7 +106,7 @@ export default function TrafficAnalytics() {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
|
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
|
||||||
<div className="grid grid-cols-7 gap-3">
|
<div className="grid grid-cols-7 gap-3">
|
||||||
{byWeekday.map((d, i) => {
|
{byWeekday.map((d) => {
|
||||||
const intensity = d.orders / maxByDow
|
const intensity = d.orders / maxByDow
|
||||||
return (
|
return (
|
||||||
<div key={d.day} className="flex flex-col items-center gap-2">
|
<div key={d.day} className="flex flex-col items-center gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
@@ -13,7 +13,7 @@ import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -204,9 +204,11 @@ export default function WorkDaySummary() {
|
|||||||
|
|
||||||
const days = data?.business_days || []
|
const days = data?.business_days || []
|
||||||
|
|
||||||
|
const anyHasCost = days.some(d => (d.total_cost || 0) > 0)
|
||||||
const chartData = [...days].reverse().map(d => ({
|
const chartData = [...days].reverse().map(d => ({
|
||||||
date: fmtDate(d.opened_at),
|
date: fmtDate(d.opened_at),
|
||||||
revenue: d.revenue,
|
revenue: d.revenue,
|
||||||
|
profit: (d.trackable_profit || 0) > 0 ? d.trackable_profit : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
|
||||||
@@ -228,15 +230,19 @@ export default function WorkDaySummary() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{chartData.length > 0 && (
|
{chartData.length > 0 && (
|
||||||
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${from} → ${to}`}>
|
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
|
||||||
<div style={{ height: 220 }}>
|
<div style={{ height: 240 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
|
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: anyHasCost ? 16 : 4, left: 4 }}>
|
||||||
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
|
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
|
||||||
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
|
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
|
||||||
<Line type="monotone" dataKey="revenue" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
|
{anyHasCost && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
|
||||||
|
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
|
||||||
|
{anyHasCost && (
|
||||||
|
<Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} activeDot={{ r: 5 }} connectNulls={false} />
|
||||||
|
)}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,13 +262,19 @@ export default function WorkDaySummary() {
|
|||||||
<TH align="right">Διάρκεια</TH>
|
<TH align="right">Διάρκεια</TH>
|
||||||
<TH align="right">Παραγγελίες</TH>
|
<TH align="right">Παραγγελίες</TH>
|
||||||
<TH align="right">Έσοδα</TH>
|
<TH align="right">Έσοδα</TH>
|
||||||
|
{anyHasCost && <TH align="right">Κόστος</TH>}
|
||||||
|
{anyHasCost && <TH align="right">Κέρδος</TH>}
|
||||||
<TH align="right">Ακυρώσεις</TH>
|
<TH align="right">Ακυρώσεις</TH>
|
||||||
<TH align="right">Σερβιτόροι</TH>
|
<TH align="right">Σερβιτόροι</TH>
|
||||||
<TH>Κατάσταση</TH>
|
<TH>Κατάσταση</TH>
|
||||||
<TH className="w-10" />
|
<TH className="w-10" />
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{days.map(d => (
|
{days.map(d => {
|
||||||
|
const margin = d.revenue > 0 && (d.trackable_profit || 0) > 0
|
||||||
|
? ((d.trackable_profit / d.revenue) * 100).toFixed(0) + '%'
|
||||||
|
: null
|
||||||
|
return (
|
||||||
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
|
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
|
||||||
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
|
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
|
||||||
<TD mono>{fmtTime(d.opened_at)}</TD>
|
<TD mono>{fmtTime(d.opened_at)}</TD>
|
||||||
@@ -270,6 +282,22 @@ export default function WorkDaySummary() {
|
|||||||
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
|
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
|
||||||
<TD mono align="right">{fmtNum(d.order_count)}</TD>
|
<TD mono align="right">{fmtNum(d.order_count)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right" className="text-slate-600">
|
||||||
|
{(d.total_cost || 0) > 0 ? fmtEUR(d.total_cost) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
|
{anyHasCost && (
|
||||||
|
<TD mono align="right">
|
||||||
|
{(d.trackable_profit || 0) > 0 ? (
|
||||||
|
<span className="text-green-700">
|
||||||
|
{fmtEUR(d.trackable_profit)}
|
||||||
|
{margin && !d.has_gap && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
|
||||||
|
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500">⚠</span>}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
)}
|
||||||
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
|
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
|
||||||
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
|
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
|
||||||
<TD><StatusBadge status={d.status} pulse /></TD>
|
<TD><StatusBadge status={d.status} pulse /></TD>
|
||||||
@@ -286,7 +314,7 @@ export default function WorkDaySummary() {
|
|||||||
)}
|
)}
|
||||||
</TD>
|
</TD>
|
||||||
</TR>
|
</TR>
|
||||||
))}
|
)})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,9 +155,144 @@ export function FilterSelect({ value, onChange, options, label, icon: Icon }) {
|
|||||||
|
|
||||||
// ── Date input ─────────────────────────────────────────────────────────────────
|
// ── Date input ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FilterDateInput({ value, onChange, label }) {
|
const GR_MONTHS = ['Ιανουάριος','Φεβρουάριος','Μάρτιος','Απρίλιος','Μάιος','Ιούνιος','Ιούλιος','Αύγουστος','Σεπτέμβριος','Οκτώβριος','Νοέμβριος','Δεκέμβριος']
|
||||||
|
const GR_DAYS = ['Κυ','Δε','Τρ','Τε','Πε','Πα','Σα']
|
||||||
|
|
||||||
|
function fmtGR(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const [y, m, d] = value.split('-')
|
||||||
|
if (!y || !m || !d) return value
|
||||||
|
return `${d}/${m}/${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDropdown({ value, onCommit, onClose }) {
|
||||||
|
const today = new Date()
|
||||||
|
const initYear = value ? parseInt(value.slice(0, 4)) : today.getFullYear()
|
||||||
|
const initMonth = value ? parseInt(value.slice(5, 7)) - 1 : today.getMonth()
|
||||||
|
|
||||||
|
const [viewYear, setViewYear] = useState(initYear)
|
||||||
|
const [viewMonth, setViewMonth] = useState(initMonth)
|
||||||
|
|
||||||
|
const selectedY = value ? parseInt(value.slice(0, 4)) : null
|
||||||
|
const selectedM = value ? parseInt(value.slice(5, 7)) - 1 : null
|
||||||
|
const selectedD = value ? parseInt(value.slice(8, 10)) : null
|
||||||
|
|
||||||
|
function prevMonth(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
|
||||||
|
else setViewMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
function nextMonth(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
|
||||||
|
else setViewMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build grid: first cell = weekday of 1st of month (0=Sun)
|
||||||
|
const firstDow = new Date(viewYear, viewMonth, 1).getDay()
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||||
|
|
||||||
|
function selectDay(e, day) {
|
||||||
|
e.stopPropagation()
|
||||||
|
const mm = String(viewMonth + 1).padStart(2, '0')
|
||||||
|
const dd = String(day).padStart(2, '0')
|
||||||
|
onCommit(`${viewYear}-${mm}-${dd}`)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayY = today.getFullYear()
|
||||||
|
const todayM = today.getMonth()
|
||||||
|
const todayD = today.getDate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}>
|
<div
|
||||||
|
className="absolute left-0 top-full mt-1 z-50 w-64 rounded-lg border border-slate-200 bg-white shadow-xl ring-1 ring-slate-100 select-none"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevMonth}
|
||||||
|
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="15 18 9 12 15 6" /></svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-[13px] font-semibold text-slate-800">
|
||||||
|
{GR_MONTHS[viewMonth]} {viewYear}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextMonth}
|
||||||
|
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="9 18 15 12 9 6" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div className="grid grid-cols-7 px-2 pt-2 pb-1">
|
||||||
|
{GR_DAYS.map(d => (
|
||||||
|
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 py-0.5">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div className="grid grid-cols-7 px-2 pb-2 gap-y-0.5">
|
||||||
|
{cells.map((day, i) => {
|
||||||
|
if (!day) return <div key={`e${i}`} />
|
||||||
|
const isSelected = day === selectedD && viewMonth === selectedM && viewYear === selectedY
|
||||||
|
const isToday = day === todayD && viewMonth === todayM && viewYear === todayY
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={e => selectDay(e, day)}
|
||||||
|
className={`
|
||||||
|
h-8 w-8 mx-auto rounded-full text-[12px] font-medium transition-colors
|
||||||
|
${isSelected
|
||||||
|
? 'bg-sky-500 text-white'
|
||||||
|
: isToday
|
||||||
|
? 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 hover:bg-sky-100'
|
||||||
|
: 'text-slate-700 hover:bg-slate-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterDateInput({ value, onChange, label }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handler(e) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}
|
||||||
|
>
|
||||||
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -166,13 +301,22 @@ export function FilterDateInput({ value, onChange, label }) {
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<input
|
<span className="text-[13px] font-normal whitespace-nowrap">
|
||||||
type="date"
|
{value
|
||||||
|
? <span className="text-slate-700">{fmtGR(value)}</span>
|
||||||
|
: <span className="text-slate-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<CalendarDropdown
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onCommit={onChange}
|
||||||
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal"
|
onClose={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</label>
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,20 +177,54 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
|||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<>
|
<>
|
||||||
{/* KPI row */}
|
{/* KPI grid — 2 rows × 4 cols */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
{(() => {
|
||||||
{[
|
const totalOrders = summary.orders?.length ?? 0
|
||||||
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
|
const tableSet = new Set(summary.orders?.map(o => o.table_name).filter(Boolean))
|
||||||
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
|
const tablesServed = tableSet.size
|
||||||
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
|
const durationHours = summary.duration_hours ?? (summary.duration_minutes != null ? summary.duration_minutes / 60 : null)
|
||||||
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
|
const durationLabel = durationHours != null
|
||||||
].map(k => (
|
? (() => { const h = Math.floor(durationHours); const m = Math.round((durationHours - h) * 60); return h > 0 ? `${h}ω ${m}λ` : `${m}λ` })()
|
||||||
|
: fmtMins(summary.duration_minutes)
|
||||||
|
const row1 = [
|
||||||
|
{ label: 'Διάρκεια Βάρδιας', value: durationLabel },
|
||||||
|
{ label: 'Μισθός / Ώρα', value: summary.hourly_rate_snapshot != null ? fmtEUR(summary.hourly_rate_snapshot) : '—' },
|
||||||
|
{ label: 'Πληρωμή Βάρδιας', value: summary.shift_pay != null ? fmtEUR(summary.shift_pay) : 'Δεν παρακολουθείται', accent: summary.shift_pay != null ? '#059669' : undefined },
|
||||||
|
{ label: 'Αρχικά Μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
|
||||||
|
]
|
||||||
|
const discrepancy = summary.cash_discrepancy
|
||||||
|
const discLabel = discrepancy == null
|
||||||
|
? '—'
|
||||||
|
: Math.abs(discrepancy) < 0.005
|
||||||
|
? '✓ Εντάξει'
|
||||||
|
: discrepancy < 0
|
||||||
|
? `Έλλειμμα ${fmtEUR(Math.abs(discrepancy))}`
|
||||||
|
: `Πλεόνασμα ${fmtEUR(discrepancy)}`
|
||||||
|
const discAccent = discrepancy == null
|
||||||
|
? undefined
|
||||||
|
: Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626'
|
||||||
|
|
||||||
|
const row2 = [
|
||||||
|
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
|
||||||
|
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
|
||||||
|
{ label: 'Παραδόθηκαν', value: summary.counted_cash_end != null ? fmtEUR(summary.counted_cash_end) : '—', accent: '#3758c9' },
|
||||||
|
{ label: 'Ταμείο', value: discLabel, accent: discAccent },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||||
|
{[row1, row2].map((row, ri) => (
|
||||||
|
<div key={ri} style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 6 }}>
|
||||||
|
{row.map(k => (
|
||||||
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
|
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
|
<div style={{ fontSize: 16, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Colour legend */}
|
{/* Colour legend */}
|
||||||
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import StatCard from '../shared/StatCard'
|
|||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import ExportButton from '../shared/ExportButton'
|
import ExportButton from '../shared/ExportButton'
|
||||||
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() {
|
function monthAgo() {
|
||||||
@@ -103,7 +103,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel
|
<Panel
|
||||||
title="Όλες οι Βάρδιες"
|
title="Όλες οι Βάρδιες"
|
||||||
subtitle={`${shifts.length} βάρδιες · ${from} → ${to}`}
|
subtitle={`${shifts.length} βάρδιες · ${fmtDateStr(from)} → ${fmtDateStr(to)}`}
|
||||||
padded={false}
|
padded={false}
|
||||||
>
|
>
|
||||||
{shifts.length === 0 ? (
|
{shifts.length === 0 ? (
|
||||||
@@ -119,9 +119,12 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
<TH>Έναρξη</TH>
|
<TH>Έναρξη</TH>
|
||||||
<TH>Λήξη</TH>
|
<TH>Λήξη</TH>
|
||||||
<TH align="right">Διάρκεια</TH>
|
<TH align="right">Διάρκεια</TH>
|
||||||
|
<TH align="right">Αμοιβή/ώρα</TH>
|
||||||
|
<TH align="right">Αποδοχές</TH>
|
||||||
<TH align="right">Αρχικά Μετρητά</TH>
|
<TH align="right">Αρχικά Μετρητά</TH>
|
||||||
<TH align="right">Εισπράχθηκαν</TH>
|
<TH align="right">Εισπράχθηκαν</TH>
|
||||||
<TH align="right">Οφείλει</TH>
|
<TH align="right">Οφείλει</TH>
|
||||||
|
<TH align="right">Ταμείο</TH>
|
||||||
<TH>Κατάσταση</TH>
|
<TH>Κατάσταση</TH>
|
||||||
<TH className="w-28" />
|
<TH className="w-28" />
|
||||||
</THead>
|
</THead>
|
||||||
@@ -143,9 +146,37 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
: <span className="text-sky-600 font-medium">— ενεργή —</span>}
|
: <span className="text-sky-600 font-medium">— ενεργή —</span>}
|
||||||
</TD>
|
</TD>
|
||||||
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
|
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{s.hourly_rate_snapshot != null
|
||||||
|
? fmtEUR(s.hourly_rate_snapshot)
|
||||||
|
: <span className="text-slate-400 text-[11px]">—</span>}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{s.shift_pay != null
|
||||||
|
? <span className="font-semibold text-emerald-700">{fmtEUR(s.shift_pay)}</span>
|
||||||
|
: <span className="text-slate-400 text-[11px]">Δεν παρακολουθείται</span>}
|
||||||
|
</TD>
|
||||||
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
|
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
|
||||||
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
|
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{s.counted_cash_end != null ? (
|
||||||
|
<span className={
|
||||||
|
s.cash_discrepancy == null || Math.abs(s.cash_discrepancy) < 0.005
|
||||||
|
? 'text-emerald-600 font-semibold'
|
||||||
|
: 'text-red-600 font-semibold'
|
||||||
|
}>
|
||||||
|
{fmtEUR(s.counted_cash_end)}
|
||||||
|
{s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
|
||||||
|
<span className="block text-[10px] font-medium">
|
||||||
|
{s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400 text-[11px]">—</span>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||||
<TD align="right">
|
<TD align="right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -170,7 +201,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
</TR>,
|
</TR>,
|
||||||
isOpen && (
|
isOpen && (
|
||||||
<tr key={`${s.id}-detail`}>
|
<tr key={`${s.id}-detail`}>
|
||||||
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
<td colSpan={12} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
||||||
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
||||||
<span>
|
<span>
|
||||||
{'Εργάσιμη Μέρα: '}
|
{'Εργάσιμη Μέρα: '}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
|||||||
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import { fmtEUR, fmtNum, avatarColor } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, avatarColor, fmtDateStr } from '../shared/reportDesignTokens'
|
||||||
|
|
||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
@@ -68,7 +68,7 @@ export default function StaffLeaderboard() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<Panel title="Κορυφαίοι" subtitle={`${from} → ${to}`}>
|
<Panel title="Κορυφαίοι" subtitle={`${fmtDateStr(from)} → ${fmtDateStr(to)}`}>
|
||||||
{top3.length === 0 ? (
|
{top3.length === 0 ? (
|
||||||
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
|
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,71 +1,72 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
// Mirrors waiter_pwa/src/store/tableColourStore.js — same localStorage key so both apps share state.
|
// Must stay in sync with waiter_pwa/src/store/tableColourStore.js DEFAULT_COLOURS.
|
||||||
|
// The PWA uses these as its hardcoded fallback; the manager uses them for "reset to defaults".
|
||||||
|
|
||||||
export const DEFAULT_COLOURS = {
|
export const DEFAULT_COLOURS = {
|
||||||
light: {
|
light: {
|
||||||
free: {
|
free: {
|
||||||
cardBg: '#d6d6d6',
|
cardBg: '#dde5ef',
|
||||||
badgeBg: '#e3e3e3',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#3b485e',
|
nameText: '#3d5270',
|
||||||
badgeText: '#adadad',
|
badgeText: '#3d5270',
|
||||||
},
|
},
|
||||||
mine: {
|
mine: {
|
||||||
cardBg: '#e83030',
|
cardBg: '#e8610a',
|
||||||
badgeBg: 'rgba(255,255,255,0.40)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#e8610a',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
cardBg: '#ffbb29',
|
cardBg: '#FF8F60',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#FF8F60',
|
||||||
},
|
},
|
||||||
partially_paid: {
|
partially_paid: {
|
||||||
cardBg: '#e89230',
|
cardBg: '#FFDC67',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#d4a800',
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
cardBg: '#79ad38',
|
cardBg: '#81D264',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#81D264',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
free: {
|
free: {
|
||||||
cardBg: '#243044',
|
cardBg: '#243044',
|
||||||
badgeBg: 'rgba(26,35,50,0.50)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#94b8d4',
|
||||||
badgeText: '#adadad',
|
badgeText: '#94b8d4',
|
||||||
},
|
},
|
||||||
mine: {
|
mine: {
|
||||||
cardBg: '#e83030',
|
cardBg: '#e8610a',
|
||||||
badgeBg: 'rgba(255,255,255,0.40)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#e8610a',
|
||||||
},
|
},
|
||||||
open: {
|
open: {
|
||||||
cardBg: '#ffbb29',
|
cardBg: '#FF8F60',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#FF8F60',
|
||||||
},
|
},
|
||||||
partially_paid: {
|
partially_paid: {
|
||||||
cardBg: '#e89230',
|
cardBg: '#FFDC67',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#d4a800',
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
cardBg: '#79ad38',
|
cardBg: '#81D264',
|
||||||
badgeBg: 'rgba(255,255,255,0.25)',
|
badgeBg: 'rgba(255,255,255,0.92)',
|
||||||
nameText: '#ffffff',
|
nameText: '#ffffff',
|
||||||
badgeText: '#ffffff',
|
badgeText: '#81D264',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,21 @@ export const fmtTime = (iso) => {
|
|||||||
}
|
}
|
||||||
export const fmtDate = (iso) => {
|
export const fmtDate = (iso) => {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
|
// Parse as local time by replacing the T separator so Date treats it as local
|
||||||
|
const d = new Date(String(iso).replace('T', ' '))
|
||||||
|
if (isNaN(d)) return String(iso)
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = d.getFullYear()
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion
|
||||||
|
export const fmtDateStr = (yyyymmdd) => {
|
||||||
|
if (!yyyymmdd) return '—'
|
||||||
|
const [y, m, d] = String(yyyymmdd).split('-')
|
||||||
|
if (!y || !m || !d) return yyyymmdd
|
||||||
|
return `${d}/${m}/${y}`
|
||||||
}
|
}
|
||||||
export const fmtDateTime = (iso) => {
|
export const fmtDateTime = (iso) => {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
|
|||||||
@@ -636,6 +636,11 @@ export default function TableCard({
|
|||||||
'4x3': Card4x3,
|
'4x3': Card4x3,
|
||||||
}[density] || Card2x2
|
}[density] || Card2x2
|
||||||
|
|
||||||
|
const reservation = table.upcoming_reservation ?? null
|
||||||
|
const reservedTime = reservation
|
||||||
|
? new Date(reservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
@@ -651,6 +656,21 @@ export default function TableCard({
|
|||||||
>
|
>
|
||||||
<CardComponent {...cardProps} />
|
<CardComponent {...cardProps} />
|
||||||
</button>
|
</button>
|
||||||
|
{reservation && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 6, left: 6, zIndex: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||||
|
background: '#4f46e5', color: '#e0e7ff',
|
||||||
|
fontSize: 9, fontWeight: 800, letterSpacing: 0.3,
|
||||||
|
borderRadius: 4, padding: '2px 6px',
|
||||||
|
}}>
|
||||||
|
🔒 {reservedTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showTip && flags.length > 0 && (
|
{showTip && flags.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user