Compare commits
10 Commits
e283b8b50f
...
603fd45eaa
| Author | SHA1 | Date | |
|---|---|---|---|
| 603fd45eaa | |||
| ee51e52acf | |||
| 26c4818aa1 | |||
| da29d73520 | |||
| c92fc27ad0 | |||
| 97d72a5868 | |||
| 5acd880e92 | |||
| d07c7634e6 | |||
| 2c9276e654 | |||
| 5dbb775308 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -26,3 +26,9 @@ dist/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Fiscal driver tests & documentation
|
||||
FISCAL-DRIVER-TESTS/
|
||||
|
||||
# Runtime data (databases, uploaded images)
|
||||
data/
|
||||
|
||||
@@ -29,7 +29,7 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
|
||||
│ sysadmin_panel (React/Vite, port 5175) │
|
||||
│ │
|
||||
│ Responsibilities: │
|
||||
│ - Site registration (generates site_id + key) │
|
||||
│ - Site registration (generates site_id + key) │
|
||||
│ - License management (expiry dates) │
|
||||
│ - Remote lock/unlock per site │
|
||||
│ - Receives periodic heartbeats from local sites │
|
||||
@@ -42,7 +42,7 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
|
||||
│ backend (FastAPI, port 8000) │
|
||||
│ SQLite DB (pos.db) │
|
||||
│ Runs on: Raspberry Pi 4 or any Linux box │
|
||||
│ Static LAN IP (e.g. 192.168.1.10) │
|
||||
│ Static LAN IP (e.g. 192.168.1.10) │
|
||||
│ │
|
||||
│ Responsibilities: │
|
||||
│ - All business logic (orders, tables, users) │
|
||||
@@ -51,12 +51,12 @@ A lightweight **cloud backend** handles licensing only: it verifies that each re
|
||||
│ - License enforcement (from cloud heartbeat) │
|
||||
└───────────┬──────────────────┬───────────────────┘
|
||||
│ LAN │ LAN
|
||||
┌───────────▼──────┐ ┌────────▼──────────────────┐
|
||||
┌───────────▼──────┐ ┌────────▼───────────────────┐
|
||||
│ waiter_pwa │ │ manager_dashboard │
|
||||
│ React PWA │ │ React web app │
|
||||
│ Port 5173 │ │ Port 5174 │
|
||||
│ Waiters' phones │ │ Supervisor tablet/laptop │
|
||||
└───────────┬──────┘ └───────────────────────────┘
|
||||
└───────────┬──────┘ └────────────────────────────┘
|
||||
│ LAN (TCP/9100)
|
||||
┌───────────▼───────────────────────────────────────┐
|
||||
│ Thermal Printers (Jolimark TP850UE) │
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- ./local_backend/license_state.json:/app/license_state.json
|
||||
- ./logo.png:/app/logo.png:ro
|
||||
- ./data/product_images:/app/data/product_images
|
||||
- ./data/avatars:/app/data/avatars
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
|
||||
5
local_backend/.dockerignore
Normal file
5
local_backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
pos.db
|
||||
license_state.json
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
@@ -9,35 +9,78 @@ from middleware.license_check import LicenseCheckMiddleware
|
||||
from services.cloud_sync import start_cloud_sync
|
||||
|
||||
# Import all models so SQLAlchemy can create their tables
|
||||
import models.user # noqa: F401
|
||||
import models.user # noqa: F401 — also registers WaiterZone
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.order # noqa: F401
|
||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
"""Apply additive schema changes that create_all won't handle."""
|
||||
"""Apply additive schema changes that create_all won't handle.
|
||||
Each migration gets its own connection so a no-op (column already exists)
|
||||
doesn't leave a dirty transaction that blocks subsequent migrations."""
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
# Add extra_cost to product_ingredients if missing
|
||||
|
||||
migrations = [
|
||||
"ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0",
|
||||
"ALTER TABLE products ADD COLUMN image_url VARCHAR",
|
||||
"ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)",
|
||||
"ALTER TABLE table_groups ADD COLUMN prefix VARCHAR",
|
||||
"ALTER TABLE table_groups ADD COLUMN color VARCHAR",
|
||||
"ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER",
|
||||
"ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT",
|
||||
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT",
|
||||
"ALTER TABLE product_options ADD COLUMN sub_choices TEXT",
|
||||
# Zone-based access control
|
||||
"""CREATE TABLE IF NOT EXISTS waiter_zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
waiter_id INTEGER NOT NULL REFERENCES users(id),
|
||||
group_id INTEGER REFERENCES table_groups(id),
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Payment tracking on items
|
||||
"ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)",
|
||||
"ALTER TABLE order_items ADD COLUMN paid_at DATETIME",
|
||||
"ALTER TABLE order_items ADD COLUMN payment_method VARCHAR",
|
||||
# Full audit log
|
||||
"""CREATE TABLE IF NOT EXISTS order_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
event_type VARCHAR NOT NULL,
|
||||
waiter_id INTEGER REFERENCES users(id),
|
||||
item_ids TEXT,
|
||||
amount REAL,
|
||||
payment_method VARCHAR,
|
||||
note TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Waiter profile fields
|
||||
"ALTER TABLE users ADD COLUMN full_name VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
|
||||
# Discounts table (future-proofed, schema ready now)
|
||||
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
item_id INTEGER REFERENCES order_items(id),
|
||||
discount_type VARCHAR NOT NULL,
|
||||
discount_value REAL NOT NULL,
|
||||
applied_by INTEGER NOT NULL REFERENCES users(id),
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reason TEXT
|
||||
)""",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# Add image_url to products if missing
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# Add group_id to tables if missing (added in Phase 3 table groups)
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
|
||||
conn.commit()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(sql))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -66,6 +109,11 @@ IMAGE_DIR = "/app/data/product_images"
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
|
||||
|
||||
# Serve waiter avatars as static files
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars")
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
|
||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||
|
||||
@@ -21,7 +21,9 @@ class Order(Base):
|
||||
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||
print_logs = relationship("PrintLog", back_populates="order")
|
||||
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
|
||||
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class OrderWaiter(Base):
|
||||
@@ -52,9 +54,15 @@ class OrderItem(Base):
|
||||
added_at = Column(DateTime, default=datetime.utcnow)
|
||||
printed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Payment tracking
|
||||
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", back_populates="order_items")
|
||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
|
||||
|
||||
|
||||
class PrintLog(Base):
|
||||
@@ -70,3 +78,44 @@ class PrintLog(Base):
|
||||
|
||||
order = relationship("Order", back_populates="print_logs")
|
||||
printer = relationship("Printer", back_populates="print_logs")
|
||||
|
||||
|
||||
class OrderAuditLog(Base):
|
||||
"""Immutable append-only audit trail for every action on an order."""
|
||||
__tablename__ = "order_audit_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
event_type = Column(String, nullable=False)
|
||||
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED)
|
||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||
payment_method = Column(String, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
order = relationship("Order", back_populates="audit_logs")
|
||||
waiter = relationship("User")
|
||||
|
||||
@property
|
||||
def waiter_name(self):
|
||||
return self.waiter.username if self.waiter else None
|
||||
|
||||
|
||||
class OrderDiscount(Base):
|
||||
"""Records a discount applied to an order or a specific item."""
|
||||
__tablename__ = "order_discounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
|
||||
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
||||
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
||||
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
applied_at = Column(DateTime, default=datetime.utcnow)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="discounts")
|
||||
item = relationship("OrderItem")
|
||||
applied_by_user = relationship("User")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
@@ -24,6 +24,7 @@ class Product(Base):
|
||||
is_available = Column(Boolean, default=True, nullable=False)
|
||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
image_url = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
@@ -40,6 +41,8 @@ class ProductOption(Base):
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
|
||||
product = relationship("Product", back_populates="options")
|
||||
|
||||
@@ -61,6 +64,10 @@ class ProductPreferenceSet(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
default_choice_id = Column(Integer, nullable=True)
|
||||
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
||||
# Shared sub-set shown for all choices that don't have disables_subset=True
|
||||
shared_subset = Column(Text, nullable=True)
|
||||
|
||||
product = relationship("Product", back_populates="preference_sets")
|
||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||
@@ -73,5 +80,10 @@ class ProductPreferenceChoice(Base):
|
||||
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
|
||||
# Per-choice inline sub-preference shown only when this choice is selected.
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
# When True this choice hides the set-level shared_subset on the PWA.
|
||||
disables_subset = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||
|
||||
@@ -8,9 +8,12 @@ class TableGroup(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
prefix = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
color = Column(String, nullable=True)
|
||||
|
||||
tables = relationship("Table", back_populates="group")
|
||||
waiter_zones = relationship("WaiterZone", back_populates="group")
|
||||
|
||||
|
||||
class Table(Base):
|
||||
|
||||
@@ -12,12 +12,18 @@ class User(Base):
|
||||
pin_hash = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
full_name = Column(String, nullable=True)
|
||||
nickname = Column(String, nullable=True)
|
||||
mobile_phone = Column(String, nullable=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
order_items = relationship("OrderItem", back_populates="added_by_user")
|
||||
order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user")
|
||||
items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user")
|
||||
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
|
||||
|
||||
primary_assignments = relationship(
|
||||
"AssistantAssignment",
|
||||
@@ -31,6 +37,21 @@ class User(Base):
|
||||
)
|
||||
|
||||
|
||||
class WaiterZone(Base):
|
||||
"""Maps a waiter to a table group they are allowed to operate in.
|
||||
If a waiter has NO rows here, they see NOTHING.
|
||||
A sentinel row with group_id=NULL means 'all zones'."""
|
||||
__tablename__ = "waiter_zones"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
waiter = relationship("User", back_populates="zone_assignments")
|
||||
group = relationship("TableGroup", back_populates="waiter_zones")
|
||||
|
||||
|
||||
class AssistantAssignment(Base):
|
||||
__tablename__ = "assistant_assignments"
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ Pillow==10.4.0
|
||||
bcrypt==4.2.0
|
||||
pyjwt==2.9.0
|
||||
httpx==0.27.2
|
||||
python-multipart==0.0.9
|
||||
|
||||
@@ -1,49 +1,22 @@
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||
_blacklisted_tokens: set[str] = set()
|
||||
|
||||
|
||||
def _make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
if token in _blacklisted_tokens:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = _make_token(user)
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@@ -53,17 +26,17 @@ def refresh(token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
_blacklisted_tokens.add(token)
|
||||
new_token = _make_token(user)
|
||||
blacklist_token(token)
|
||||
new_token = make_token(user)
|
||||
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
blacklist_token(token)
|
||||
return {"status": "logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
from routers.auth import decode_token
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||
_blacklisted_tokens: set[str] = set()
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
|
||||
|
||||
def make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
if token in _blacklisted_tokens:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
def blacklist_token(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
|
||||
@@ -5,30 +5,48 @@ from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.user import User, AssistantAssignment
|
||||
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import Table
|
||||
from models.product import Product
|
||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
|
||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PrintOrderRequest(BaseModel):
|
||||
printer_id: int
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
|
||||
if user.role in ("manager", "sysadmin"):
|
||||
return True
|
||||
if order.opened_by == user.id:
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
if not zones:
|
||||
return False
|
||||
if any(z.group_id is None for z in zones):
|
||||
return True
|
||||
if any(ow.waiter_id == user.id for ow in order.waiters):
|
||||
return True
|
||||
# Assistant check: user is assistant to any waiter assigned to this order
|
||||
assigned_ids = {ow.waiter_id for ow in order.waiters}
|
||||
assistant_of = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.assistant_waiter_id == user.id,
|
||||
AssistantAssignment.primary_waiter_id.in_(assigned_ids),
|
||||
).first()
|
||||
return assistant_of is not None
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
if not table:
|
||||
return False
|
||||
allowed_group_ids = {z.group_id for z in zones}
|
||||
return table.group_id in allowed_group_ids
|
||||
|
||||
|
||||
def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
||||
item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
|
||||
db.add(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,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/", response_model=List[OrderOut])
|
||||
@@ -83,16 +101,16 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
|
||||
db.add(order)
|
||||
db.flush()
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/{order_id}/items", response_model=OrderOut)
|
||||
@router.post("/{order_id}/items", response_model=AddItemsResponse)
|
||||
def add_items(
|
||||
order_id: int,
|
||||
body: AddItemsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -109,7 +127,6 @@ def add_items(
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
if not product or not product.is_available:
|
||||
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
|
||||
# Calculate extra cost from selected options
|
||||
extra_cost = sum(
|
||||
(o.price_delta or o.extra_cost or 0.0)
|
||||
for o in (item_in.selected_options or [])
|
||||
@@ -119,7 +136,7 @@ def add_items(
|
||||
product_id=item_in.product_id,
|
||||
added_by=user.id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=product.base_price + extra_cost, # price snapshot with options
|
||||
unit_price=product.base_price + extra_cost,
|
||||
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,
|
||||
notes=item_in.notes,
|
||||
@@ -128,13 +145,13 @@ def add_items(
|
||||
db.flush()
|
||||
new_item_ids.append(item.id)
|
||||
|
||||
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
|
||||
# Printer routing runs in background — must never block the order save
|
||||
background_tasks.add_task(route_and_print, order_id, new_item_ids)
|
||||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||||
|
||||
return order
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
@@ -155,6 +172,7 @@ def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
item.status = "cancelled"
|
||||
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -171,16 +189,25 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
now = datetime.utcnow()
|
||||
total_paid = 0.0
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = now
|
||||
item.payment_method = body.payment_method
|
||||
total_paid += item.unit_price * item.quantity
|
||||
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||
).count()
|
||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||
|
||||
paid_ids = [i.id for i in items]
|
||||
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
||||
amount=total_paid, payment_method=body.payment_method)
|
||||
db.commit()
|
||||
return {"status": order.status, "paid_item_ids": [i.id for i in items]}
|
||||
return {"status": order.status, "paid_item_ids": paid_ids}
|
||||
|
||||
|
||||
@router.post("/{order_id}/close")
|
||||
@@ -195,6 +222,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
return {"status": "closed"}
|
||||
|
||||
@@ -207,6 +235,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -234,3 +263,58 @@ def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db),
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{order_id}/print")
|
||||
def print_order(
|
||||
order_id: int,
|
||||
body: PrintOrderRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
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")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = opener.username if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
grand_total = sum(i["total"] for i in items_data)
|
||||
|
||||
receipt = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
|
||||
"status": order.status,
|
||||
"items": items_data,
|
||||
"total": grand_total,
|
||||
"notes": order.notes,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
return {"status": "printing"}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
ProductCreate, ProductUpdate, ProductOut,
|
||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
PreferenceSetCreate,
|
||||
)
|
||||
@@ -24,7 +26,13 @@ def _replace_options(db, product, options):
|
||||
db.delete(opt)
|
||||
db.flush()
|
||||
for opt in options:
|
||||
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
))
|
||||
|
||||
|
||||
def _replace_ingredients(db, product, ingredients):
|
||||
@@ -40,11 +48,29 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
for ps in sets:
|
||||
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
|
||||
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
|
||||
new_set = ProductPreferenceSet(
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
created_choices = []
|
||||
for ch in ps.choices:
|
||||
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
|
||||
sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None
|
||||
choice = ProductPreferenceChoice(
|
||||
set_id=new_set.id,
|
||||
name=ch.name,
|
||||
extra_cost=ch.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
disables_subset=ch.disables_subset,
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
created_choices.append(choice)
|
||||
if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices):
|
||||
new_set.default_choice_id = created_choices[ps.default_choice_index].id
|
||||
|
||||
|
||||
# ── Categories ────────────────────────────────────────────────────────────────
|
||||
@@ -101,25 +127,32 @@ def list_products(all: bool = False, db: Session = Depends(get_db), user: User =
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
q = q.filter(Product.is_available == True)
|
||||
return q.all()
|
||||
return q.order_by(Product.sort_order, Product.id).all()
|
||||
|
||||
|
||||
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.id).first()
|
||||
if product:
|
||||
product.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@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)):
|
||||
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for opt in body.options:
|
||||
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
for ps in body.preference_sets:
|
||||
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
for ch in ps.choices:
|
||||
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
@@ -154,7 +187,6 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
|
||||
# Delete old image if exists
|
||||
if product.image_url:
|
||||
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
|
||||
if os.path.exists(old_path):
|
||||
@@ -175,9 +207,18 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
product.is_available = False
|
||||
if hard:
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
|
||||
)
|
||||
db.delete(product)
|
||||
else:
|
||||
product.is_available = False
|
||||
db.commit()
|
||||
|
||||
@@ -1,49 +1,170 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
||||
from models.user import User
|
||||
from models.table import Table
|
||||
from models.printer import Printer
|
||||
from schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/shift")
|
||||
def shift_summary(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
target = report_date or date.today()
|
||||
start = datetime.combine(target, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
"""Payments collected per waiter — based on paid_by on order items."""
|
||||
if from_dt and to_dt:
|
||||
start = datetime.fromisoformat(from_dt)
|
||||
end = datetime.fromisoformat(to_dt)
|
||||
else:
|
||||
target = report_date or date.today()
|
||||
start = datetime.combine(target, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
q = db.query(Order).filter(Order.opened_at >= start, Order.opened_at < end)
|
||||
q = db.query(OrderItem).filter(
|
||||
OrderItem.status == "paid",
|
||||
OrderItem.paid_at >= start,
|
||||
OrderItem.paid_at < end,
|
||||
)
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
orders = q.all()
|
||||
q = q.filter(OrderItem.paid_by == waiter_id)
|
||||
items = q.all()
|
||||
|
||||
summary = {}
|
||||
for order in orders:
|
||||
waiter = db.query(User).filter(User.id == order.opened_by).first()
|
||||
key = waiter.username if waiter else "unknown"
|
||||
if key not in summary:
|
||||
summary[key] = {"orders": 0, "items": 0, "total": 0.0}
|
||||
summary[key]["orders"] += 1
|
||||
for item in order.items:
|
||||
if item.status in ("active", "paid"):
|
||||
summary[key]["items"] += item.quantity
|
||||
summary[key]["total"] += item.unit_price * item.quantity
|
||||
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
|
||||
return {"date": str(target), "waiters": summary}
|
||||
# Build per-waiter summary keyed by waiter_id
|
||||
summary: dict[int, dict] = {}
|
||||
for item in items:
|
||||
wid = item.paid_by
|
||||
if wid not in summary:
|
||||
w = waiters_db.get(wid)
|
||||
wname = (w.full_name or w.username) if w else f"#{wid}"
|
||||
summary[wid] = {
|
||||
"waiter_id": wid,
|
||||
"waiter_name": wname,
|
||||
"items": 0,
|
||||
"total": 0.0,
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
if oid not in summary[wid]["order_data"]:
|
||||
order = db.query(Order).filter(Order.id == oid).first()
|
||||
summary[wid]["order_data"][oid] = {
|
||||
"id": oid,
|
||||
"time_open": order.opened_at.strftime("%H:%M") if order else "",
|
||||
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
|
||||
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||
"total": 0.0,
|
||||
"items": [],
|
||||
}
|
||||
summary[wid]["order_data"][oid]["total"] += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
summary[wid]["order_data"][oid]["items"].append(
|
||||
{"name": product_name, "quantity": item.quantity}
|
||||
)
|
||||
|
||||
result = []
|
||||
for entry in summary.values():
|
||||
entry["orders"] = len(entry["order_data"])
|
||||
entry["order_data"] = list(entry["order_data"].values())
|
||||
result.append(entry)
|
||||
|
||||
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
||||
|
||||
|
||||
@router.get("/shift/orders")
|
||||
def shift_orders_summary(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Items sent (added) per waiter — regardless of payment status."""
|
||||
if from_dt and to_dt:
|
||||
start = datetime.fromisoformat(from_dt)
|
||||
end = datetime.fromisoformat(to_dt)
|
||||
else:
|
||||
target = report_date or date.today()
|
||||
start = datetime.combine(target, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
q = db.query(OrderItem).filter(
|
||||
OrderItem.status.in_(["active", "paid"]),
|
||||
OrderItem.added_at >= start,
|
||||
OrderItem.added_at < end,
|
||||
)
|
||||
if waiter_id:
|
||||
q = q.filter(OrderItem.added_by == waiter_id)
|
||||
items = q.all()
|
||||
|
||||
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
|
||||
summary: dict[int, dict] = {}
|
||||
for item in items:
|
||||
wid = item.added_by
|
||||
if wid not in summary:
|
||||
w = waiters_db.get(wid)
|
||||
wname = (w.full_name or w.username) if w else f"#{wid}"
|
||||
summary[wid] = {
|
||||
"waiter_id": wid,
|
||||
"waiter_name": wname,
|
||||
"items": 0,
|
||||
"total": 0.0,
|
||||
"order_data": {},
|
||||
}
|
||||
summary[wid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
summary[wid]["total"] += val
|
||||
|
||||
oid = item.order_id
|
||||
if oid not in summary[wid]["order_data"]:
|
||||
order = db.query(Order).filter(Order.id == oid).first()
|
||||
summary[wid]["order_data"][oid] = {
|
||||
"id": oid,
|
||||
"time_open": order.opened_at.strftime("%H:%M") if order else "",
|
||||
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
|
||||
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||
"total": 0.0,
|
||||
"items": [],
|
||||
}
|
||||
summary[wid]["order_data"][oid]["total"] += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
summary[wid]["order_data"][oid]["items"].append(
|
||||
{"name": product_name, "quantity": item.quantity}
|
||||
)
|
||||
|
||||
result = []
|
||||
for entry in summary.values():
|
||||
entry["orders"] = len(entry["order_data"])
|
||||
entry["order_data"] = list(entry["order_data"].values())
|
||||
result.append(entry)
|
||||
|
||||
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
||||
|
||||
|
||||
@router.get("/orders/history", response_model=List[OrderOut])
|
||||
@@ -52,6 +173,7 @@ def order_history(
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
waiter_id: Optional[int] = None,
|
||||
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||
table_id: Optional[int] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -66,6 +188,8 @@ def order_history(
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
if order_status:
|
||||
q = q.filter(Order.status == order_status)
|
||||
if table_id:
|
||||
q = q.filter(Order.table_id == table_id)
|
||||
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
|
||||
@@ -84,3 +208,233 @@ def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_m
|
||||
"order_id": active_order.id if active_order else None,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/printers")
|
||||
def printer_totals(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Returns totals per printer based on print_log entries in the date range."""
|
||||
q = db.query(PrintLog).filter(PrintLog.success == True)
|
||||
if from_date:
|
||||
q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_date))
|
||||
if to_date:
|
||||
q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_date))
|
||||
logs = q.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()}
|
||||
|
||||
# summary[pid] — aggregated totals
|
||||
summary: dict[int, dict] = {}
|
||||
# order_map[pid][order_id] — per-order detail with items
|
||||
order_map: dict[int, dict] = {}
|
||||
|
||||
for log in logs:
|
||||
pid = log.printer_id
|
||||
if pid not in summary:
|
||||
printer = printers_db.get(pid)
|
||||
summary[pid] = {
|
||||
"printer_id": pid,
|
||||
"printer_name": printer.name if printer else f"Printer #{pid}",
|
||||
"print_jobs": 0,
|
||||
"orders": set(),
|
||||
"items": 0,
|
||||
"total": 0.0,
|
||||
}
|
||||
order_map[pid] = {}
|
||||
|
||||
summary[pid]["print_jobs"] += 1
|
||||
summary[pid]["orders"].add(log.order_id)
|
||||
|
||||
oid = log.order_id
|
||||
if oid not in order_map[pid]:
|
||||
order = db.query(Order).filter(Order.id == oid).first()
|
||||
order_map[pid][oid] = {
|
||||
"order_id": oid,
|
||||
"time": log.printed_at.strftime("%H:%M"),
|
||||
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||
"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"):
|
||||
summary[pid]["items"] += item.quantity
|
||||
val = item.unit_price * item.quantity
|
||||
summary[pid]["total"] += val
|
||||
order_map[pid][oid]["total"] += val
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
order_map[pid][oid]["items"].append({"name": product_name, "quantity": item.quantity})
|
||||
|
||||
result = []
|
||||
for pid, entry in summary.items():
|
||||
entry["orders"] = len(entry["orders"])
|
||||
entry["order_data"] = list(order_map.get(pid, {}).values())
|
||||
result.append(entry)
|
||||
return {"printers": result}
|
||||
|
||||
|
||||
class PrintWaiterReportBody(BaseModel):
|
||||
waiter_name: str
|
||||
printer_id: int
|
||||
mode: str # "simple" | "extensive"
|
||||
from_dt: str
|
||||
to_dt: str
|
||||
|
||||
|
||||
class PrintPrinterReportBody(BaseModel):
|
||||
printer_target_id: int
|
||||
printer_id: int
|
||||
mode: str # "simple" | "extensive"
|
||||
from_dt: str
|
||||
to_dt: str
|
||||
|
||||
|
||||
class PrintOrderBody(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
|
||||
@router.post("/print/waiter")
|
||||
def print_waiter(
|
||||
body: PrintWaiterReportBody,
|
||||
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")
|
||||
|
||||
from_dt = datetime.fromisoformat(body.from_dt)
|
||||
to_dt = datetime.fromisoformat(body.to_dt)
|
||||
|
||||
# Gather orders for this waiter in time range
|
||||
waiter = db.query(User).filter(User.username == body.waiter_name).first()
|
||||
q = db.query(Order).filter(
|
||||
Order.opened_at >= from_dt,
|
||||
Order.opened_at <= to_dt,
|
||||
)
|
||||
if waiter:
|
||||
q = q.filter(Order.opened_by == waiter.id)
|
||||
else:
|
||||
q = q.filter(False)
|
||||
orders = q.all()
|
||||
|
||||
# Enrich with table names
|
||||
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
order_data = []
|
||||
for o in orders:
|
||||
active_items = [i for i in o.items if i.status in ("active", "paid")]
|
||||
total = sum(i.unit_price * i.quantity for i in active_items)
|
||||
order_data.append({
|
||||
"id": o.id,
|
||||
"time_open": o.opened_at.strftime("%H:%M"),
|
||||
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "",
|
||||
"table": tables.get(o.table_id, f"#{o.table_id}"),
|
||||
"total": total,
|
||||
"items": [
|
||||
{"name": (i.product.name if i.product else f"#{i.product_id}"), "quantity": i.quantity}
|
||||
for i in active_items
|
||||
],
|
||||
})
|
||||
|
||||
items_count = sum(
|
||||
i.quantity for o in orders for i in o.items if i.status in ("active", "paid")
|
||||
)
|
||||
grand_total = sum(d["total"] for d in order_data)
|
||||
|
||||
report = {
|
||||
"waiter_name": body.waiter_name,
|
||||
"orders": len(orders),
|
||||
"items": items_count,
|
||||
"total": grand_total,
|
||||
"order_data": order_data if body.mode == "extensive" else [],
|
||||
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
|
||||
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@router.post("/print/printer")
|
||||
def print_printer_totals(
|
||||
body: PrintPrinterReportBody,
|
||||
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")
|
||||
|
||||
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()}
|
||||
|
||||
# Build per-order entries keyed by order_id; each log may add more items
|
||||
order_map: dict = {}
|
||||
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
|
||||
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())
|
||||
|
||||
report = {
|
||||
"printer_name": target_name,
|
||||
"print_jobs": len(logs),
|
||||
"orders": len(order_map),
|
||||
"items": items_count,
|
||||
"total": grand_total,
|
||||
"order_data": order_data if body.mode == "extensive" else [],
|
||||
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
|
||||
"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)
|
||||
return {"status": "printing"}
|
||||
|
||||
@@ -38,6 +38,11 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
|
||||
}
|
||||
|
||||
|
||||
@router.get("/printers", response_model=List[PrinterOut])
|
||||
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(Printer).filter(Printer.is_active == True).all()
|
||||
|
||||
|
||||
@router.post("/printers/test")
|
||||
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List
|
||||
from database import get_db
|
||||
from models.table import Table, TableGroup
|
||||
from models.order import Order
|
||||
from models.user import User
|
||||
from models.user import User, WaiterZone
|
||||
from schemas.table import (
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
group = TableGroup(name=body.name, sort_order=sort_order)
|
||||
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
@@ -59,17 +59,49 @@ def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
|
||||
# ── Tables ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _next_global_number(db: Session) -> int:
|
||||
last = db.query(Table).order_by(Table.number.desc()).first()
|
||||
return (last.number + 1) if last else 1
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TableOut])
|
||||
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Table)
|
||||
if not include_inactive:
|
||||
q = q.filter(Table.is_active == True)
|
||||
return q.order_by(Table.group_id, Table.number).all()
|
||||
|
||||
# Zone-based filtering for waiters
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
# No zone rows → sees nothing
|
||||
if not zones:
|
||||
return []
|
||||
# Any row with group_id=None → sees all tables (all-zones sentinel)
|
||||
has_all_zones = any(z.group_id is None for z in zones)
|
||||
if not has_all_zones:
|
||||
allowed_group_ids = [z.group_id for z in zones]
|
||||
q = q.filter(Table.group_id.in_(allowed_group_ids))
|
||||
|
||||
tables = q.order_by(Table.group_id, Table.number).all()
|
||||
|
||||
active_table_ids = {
|
||||
row[0] for row in db.query(Order.table_id).filter(
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for t in tables:
|
||||
out = TableOut.model_validate(t)
|
||||
out.has_active_order = t.id in active_table_ids
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = Table(**body.model_dump())
|
||||
number = _next_global_number(db)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
@@ -80,12 +112,30 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
|
||||
# Group-local label numbering: find highest suffix already used in this group
|
||||
existing_in_group = (
|
||||
db.query(Table)
|
||||
.filter(Table.group_id == body.group_id)
|
||||
.all()
|
||||
) if body.group_id else []
|
||||
|
||||
# Extract trailing integers from existing labels that start with this prefix
|
||||
used = []
|
||||
for t in existing_in_group:
|
||||
if t.label and t.label.startswith(body.name_prefix):
|
||||
suffix = t.label[len(body.name_prefix):]
|
||||
if suffix.isdigit():
|
||||
used.append(int(suffix))
|
||||
start_label_n = (max(used) + 1) if used else 1
|
||||
|
||||
created = []
|
||||
for i in range(body.count):
|
||||
n = body.start_number + i
|
||||
label_n = start_label_n + i
|
||||
global_number = _next_global_number(db)
|
||||
table = Table(
|
||||
number=n,
|
||||
label=f"{body.name_prefix}{n}",
|
||||
number=global_number,
|
||||
label=f"{body.name_prefix}{label_n}",
|
||||
group_id=body.group_id,
|
||||
is_active=True,
|
||||
)
|
||||
@@ -115,13 +165,22 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete or deactivate a table with an active order"
|
||||
)
|
||||
if hard:
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete table with active order")
|
||||
# Delete all past (non-active) orders for this table so FK constraint doesn't block deletion.
|
||||
# Active orders are already blocked above. Items/waiters/print_logs cascade via ORM.
|
||||
past_orders = db.query(Order).filter(Order.table_id == table_id).all()
|
||||
for order in past_orders:
|
||||
db.delete(order)
|
||||
db.flush()
|
||||
db.delete(table)
|
||||
else:
|
||||
table.is_active = False
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import os
|
||||
import uuid
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
|
||||
class ResetPinRequest:
|
||||
def __init__(self, pin: str):
|
||||
self.pin = pin
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
@@ -35,9 +45,7 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
|
||||
@router.put("/{waiter_id}", response_model=UserOut)
|
||||
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(waiter, field, value)
|
||||
db.commit()
|
||||
@@ -47,9 +55,7 @@ def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db
|
||||
|
||||
@router.put("/{waiter_id}/reset-pin")
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
db.commit()
|
||||
return {"status": "pin reset"}
|
||||
@@ -57,9 +63,7 @@ def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: Use
|
||||
|
||||
@router.put("/{waiter_id}/block")
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.is_active = not waiter.is_active
|
||||
db.commit()
|
||||
return {"is_active": waiter.is_active}
|
||||
@@ -67,13 +71,79 @@ def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Dep
|
||||
|
||||
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not waiter:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Avatar upload / delete ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/avatar", response_model=UserOut)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
dest = os.path.join(AVATAR_DIR, filename)
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
waiter.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
# ── Zone assignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/{waiter_id}/zones")
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
|
||||
- all_zones=True → single NULL group_id row (sees everything)
|
||||
- group_ids=[1,2] → rows for groups 1 and 2 only
|
||||
- group_ids=[] → no rows at all (sees nothing)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=None))
|
||||
elif body.group_ids:
|
||||
for gid in body.group_ids:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=gid))
|
||||
|
||||
db.commit()
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all()
|
||||
return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]}
|
||||
|
||||
|
||||
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
|
||||
@@ -42,6 +42,22 @@ class OrderItemOut(BaseModel):
|
||||
status: str
|
||||
added_at: datetime
|
||||
printed: bool
|
||||
paid_by: Optional[int] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrintResultOut(BaseModel):
|
||||
printer_name: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsResponse(BaseModel):
|
||||
order: "OrderOut"
|
||||
print_results: List[PrintResultOut]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -52,6 +68,7 @@ class OrderCreate(BaseModel):
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
||||
|
||||
|
||||
class AssignWaiterRequest(BaseModel):
|
||||
@@ -63,6 +80,21 @@ class OrderWaiterOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
event_type: str
|
||||
waiter_id: Optional[int] = None
|
||||
waiter_name: Optional[str] = None # resolved server-side
|
||||
item_ids: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
payment_method: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
@@ -74,5 +106,6 @@ class OrderOut(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
items: List[OrderItemOut] = []
|
||||
waiters: List[OrderWaiterOut] = []
|
||||
audit_logs: List[AuditLogOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from pydantic import BaseModel, model_validator, field_validator
|
||||
from typing import Optional, List, Any
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
@@ -30,21 +31,43 @@ class CategoryReorderItem(BaseModel):
|
||||
|
||||
# ── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class OptionSubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
pass
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
|
||||
class ProductOptionOut(ProductOptionBase):
|
||||
id: int
|
||||
product_id: int
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_option_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else []
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Ingredients ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,39 +87,108 @@ class ProductIngredientOut(ProductIngredientBase):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
||||
# ── Sub-choices (nested under a preference choice) ────────────────────────────
|
||||
|
||||
class PreferenceChoiceBase(BaseModel):
|
||||
class SubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceCreate(PreferenceChoiceBase):
|
||||
pass
|
||||
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||
|
||||
class SharedSubsetChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(PreferenceChoiceBase):
|
||||
class SharedSubset(BaseModel):
|
||||
name: str
|
||||
choices: List[SharedSubsetChoice] = []
|
||||
|
||||
|
||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PreferenceChoiceCreate(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(BaseModel):
|
||||
id: int
|
||||
set_id: int
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = []
|
||||
else:
|
||||
parsed = []
|
||||
return {
|
||||
'id': data.id,
|
||||
'set_id': data.set_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
'disables_subset': data.disables_subset or False,
|
||||
}
|
||||
return data
|
||||
|
||||
class PreferenceSetBase(BaseModel):
|
||||
|
||||
class PreferenceSetCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PreferenceSetCreate(PreferenceSetBase):
|
||||
choices: List[PreferenceChoiceCreate] = []
|
||||
default_choice_index: Optional[int] = None # index into choices (0-based)
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
|
||||
|
||||
class PreferenceSetOut(PreferenceSetBase):
|
||||
class PreferenceSetOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
name: str
|
||||
choices: List[PreferenceChoiceOut] = []
|
||||
default_choice_id: Optional[int] = None
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_shared_subset(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'shared_subset'):
|
||||
raw = data.shared_subset
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
else:
|
||||
parsed = None
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'choices': list(data.choices),
|
||||
'default_choice_id': data.default_choice_id,
|
||||
'shared_subset': parsed,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,6 +198,7 @@ class ProductBase(BaseModel):
|
||||
base_price: float
|
||||
is_available: bool = True
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
@@ -120,11 +213,17 @@ class ProductUpdate(BaseModel):
|
||||
base_price: Optional[float] = None
|
||||
is_available: Optional[bool] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class ProductOut(ProductBase):
|
||||
id: int
|
||||
options: List[ProductOptionOut] = []
|
||||
|
||||
@@ -4,40 +4,45 @@ from typing import Optional, List
|
||||
|
||||
class TableGroupCreate(BaseModel):
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TableBase(BaseModel):
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TableCreate(TableBase):
|
||||
pass
|
||||
class TableCreate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
|
||||
|
||||
class TableBatchCreate(BaseModel):
|
||||
group_id: Optional[int] = None
|
||||
count: int
|
||||
name_prefix: str # e.g. "Out-" → Out-1, Out-2 ...
|
||||
start_number: int = 1
|
||||
name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ...
|
||||
# start_number is computed on the backend from existing tables in the group
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
number: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
@@ -48,10 +53,15 @@ class TableFloorplanUpdate(BaseModel):
|
||||
floor_y: float
|
||||
|
||||
|
||||
class TableOut(TableBase):
|
||||
class TableOut(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
has_active_order: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool = True
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -17,15 +21,35 @@ class UserUpdate(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
id: int
|
||||
waiter_id: int
|
||||
group_id: Optional[int] = None # None = all zones
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
zone_assignments: List[WaiterZoneOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetZonesRequest(BaseModel):
|
||||
"""Replace all zone assignments for a waiter in one call.
|
||||
group_ids=[] means remove all (sees nothing).
|
||||
group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel."""
|
||||
group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel
|
||||
all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row
|
||||
|
||||
|
||||
class AssistantAssignmentOut(BaseModel):
|
||||
id: int
|
||||
primary_waiter_id: int
|
||||
|
||||
@@ -160,6 +160,173 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
||||
p.cut()
|
||||
|
||||
|
||||
# ── On-demand report / receipt prints ────────────────────────────────────────
|
||||
|
||||
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
||||
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
||||
try:
|
||||
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)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Σερβιτορος: {report['waiter_name']}\n")
|
||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
if mode == "extensive" and report.get("order_data"):
|
||||
_divider(p)
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
|
||||
_divider(p)
|
||||
for od in report["order_data"]:
|
||||
# Build right-aligned total: "HH:MM - HH:MM - TABLE . . . 9.99e"
|
||||
time_open = od.get("time_open", "")
|
||||
time_close = od.get("time_close", "")
|
||||
table = od["table"]
|
||||
value = f"{od['total']:.2f}e"
|
||||
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
||||
prefix = f"{times_part} - {table}"
|
||||
gap = LINE_WIDTH - len(prefix) - len(value)
|
||||
if gap < 3:
|
||||
line = f"{prefix} {value}"
|
||||
else:
|
||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||
line = f"{prefix}{dots}{value}"
|
||||
_raw_text(p, line + "\n")
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_waiter_report failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
||||
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
||||
try:
|
||||
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)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
|
||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
|
||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
if mode == "extensive" and report.get("order_data"):
|
||||
_divider(p)
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_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.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_printer_report failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
def print_order_receipt(ip: str, port: int, receipt: dict):
|
||||
"""Print a manager-triggered order receipt."""
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Τραπεζι: {receipt['table_name']}\n")
|
||||
_raw_text(p, f"Σερβιτορος: {receipt['waiter_name']}\n")
|
||||
_raw_text(p, f"Ανοιχτηκε: {receipt['opened_at']}\n")
|
||||
if receipt.get("closed_at"):
|
||||
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
for item in receipt.get("items", []):
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||
|
||||
_divider(p)
|
||||
|
||||
if receipt.get("notes"):
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Σημ: {receipt['notes']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {receipt['total']:.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_order_receipt failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||
|
||||
def route_and_print(order_id: int, item_ids: List[int]):
|
||||
@@ -169,58 +336,76 @@ def route_and_print(order_id: int, item_ids: List[int]):
|
||||
"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
logger.error("route_and_print: order %s not found", order_id)
|
||||
return
|
||||
|
||||
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
|
||||
|
||||
# Group items by printer zone
|
||||
zone_map: dict[int, List[OrderItem]] = {}
|
||||
unzoned: List[OrderItem] = []
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
if product and product.printer_zone_id:
|
||||
zone_map.setdefault(product.printer_zone_id, []).append(item)
|
||||
else:
|
||||
unzoned.append(item)
|
||||
|
||||
if unzoned:
|
||||
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
|
||||
|
||||
for printer_id, zone_items in zone_map.items():
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
logger.warning("Printer %s not found or inactive", printer_id)
|
||||
continue
|
||||
|
||||
success = False
|
||||
error_msg = None
|
||||
try:
|
||||
p = _get_printer(printer.ip_address, printer.port)
|
||||
_print_kitchen_ticket(p, order, zone_items, db)
|
||||
p.close()
|
||||
success = True
|
||||
# Mark items as printed
|
||||
for item in zone_items:
|
||||
item.printed = True
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
|
||||
|
||||
log = PrintLog(
|
||||
order_id=order_id,
|
||||
printer_id=printer_id,
|
||||
item_ids=json.dumps([i.id for i in zone_items]),
|
||||
success=success,
|
||||
error_message=error_msg,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
_do_route_and_print(order_id, item_ids, db)
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||
"""
|
||||
Synchronous variant used when the caller needs print results.
|
||||
Returns a list of per-printer result dicts:
|
||||
{ printer_name, success, error }
|
||||
"""
|
||||
return _do_route_and_print(order_id, item_ids, db)
|
||||
|
||||
|
||||
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||
results = []
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
logger.error("route_and_print: order %s not found", order_id)
|
||||
return results
|
||||
|
||||
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
|
||||
|
||||
# Group items by printer zone
|
||||
zone_map: dict[int, List[OrderItem]] = {}
|
||||
unzoned: List[OrderItem] = []
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
if product and product.printer_zone_id:
|
||||
zone_map.setdefault(product.printer_zone_id, []).append(item)
|
||||
else:
|
||||
unzoned.append(item)
|
||||
|
||||
if unzoned:
|
||||
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
|
||||
|
||||
for printer_id, zone_items in zone_map.items():
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
logger.warning("Printer %s not found or inactive", printer_id)
|
||||
results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"})
|
||||
continue
|
||||
|
||||
success = False
|
||||
error_msg = None
|
||||
try:
|
||||
p = _get_printer(printer.ip_address, printer.port)
|
||||
_print_kitchen_ticket(p, order, zone_items, db)
|
||||
p.close()
|
||||
success = True
|
||||
for item in zone_items:
|
||||
item.printed = True
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
|
||||
|
||||
log = PrintLog(
|
||||
order_id=order_id,
|
||||
printer_id=printer_id,
|
||||
item_ids=json.dumps([i.id for i in zone_items]),
|
||||
success=success,
|
||||
error_message=error_msg,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
results.append({"printer_name": printer.name, "success": success, "error": error_msg})
|
||||
|
||||
return results
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>POS Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
79
manager_dashboard/src/components/DateInput.jsx
Normal file
79
manager_dashboard/src/components/DateInput.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* DateInput / DateTimeInput
|
||||
*
|
||||
* Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US).
|
||||
* These wrappers overlay the native input with a visible DD/MM/YYYY display
|
||||
* while keeping the full native picker UX (click, keyboard, mobile wheel).
|
||||
*
|
||||
* Props mirror a plain <input>: value (YYYY-MM-DD or YYYY-MM-DDTHH:MM),
|
||||
* onChange (receives the same synthetic event), className.
|
||||
*/
|
||||
import { useRef } from 'react'
|
||||
|
||||
function formatDateGR(value) {
|
||||
// value is "YYYY-MM-DD"
|
||||
if (!value) return ''
|
||||
const [y, m, d] = value.split('-')
|
||||
if (!y || !m || !d) return value
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatDateTimeGR(value) {
|
||||
// value is "YYYY-MM-DDTHH:MM"
|
||||
if (!value) return ''
|
||||
const [datePart, timePart] = value.split('T')
|
||||
if (!datePart) return value
|
||||
const [y, m, d] = datePart.split('-')
|
||||
if (!y || !m || !d) return value
|
||||
return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}`
|
||||
}
|
||||
|
||||
export function DateInput({ value, onChange, className = '', ...rest }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
{/* Visible display */}
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
|
||||
</div>
|
||||
{/* Native input — invisible but functional (provides the picker) */}
|
||||
<input
|
||||
ref={ref}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
|
||||
</div>
|
||||
<input
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,24 +2,232 @@ import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||
|
||||
function elapsed(openedAt) {
|
||||
const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (diff < 60) return `${diff}λ`
|
||||
return `${Math.floor(diff / 60)}ω ${diff % 60}λ`
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const COLORS = {
|
||||
open: {
|
||||
label: 'Ανοιχτό',
|
||||
tint: '#eef7f0', tintStrong: '#d7ecdc',
|
||||
accent: '#2f9e5e', ink: '#1f7042',
|
||||
},
|
||||
partially_paid: {
|
||||
label: 'Μερική πληρ.',
|
||||
tint: '#f4eefb', tintStrong: '#e3d4f3',
|
||||
accent: '#7a44c9', ink: '#57309a',
|
||||
},
|
||||
free: {
|
||||
label: 'Ελεύθερο',
|
||||
tint: '#f4f4f2', tintStrong: '#dfe2e6',
|
||||
accent: '#8a9099', ink: '#5a6169',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatEuro(n) {
|
||||
return '€' + parseFloat(n).toFixed(2)
|
||||
}
|
||||
|
||||
function formatDuration(openedAt) {
|
||||
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
function occupiedMinsFromDate(openedAt) {
|
||||
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
}
|
||||
|
||||
function orderTotal(items = []) {
|
||||
return items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.toFixed(2)
|
||||
}
|
||||
|
||||
function avatarColor(name) {
|
||||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return palette[h % palette.length]
|
||||
}
|
||||
|
||||
function WaiterBubble({ waiter, size = 26 }) {
|
||||
// waiter: { name, avatarUrl }
|
||||
if (waiter.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── V1 Table Card ────────────────────────────────────────────────────────────
|
||||
function TableCardV1({ name, status, amount, openedAt, waiters = [], onClick }) {
|
||||
const s = COLORS[status] || COLORS.free
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
||||
const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
|
||||
const showMulti = waiters.length >= 3
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false) }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: '100%', minWidth: 330, height: 200,
|
||||
padding: '16px 18px 16px 24px',
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 14,
|
||||
boxShadow: pressed
|
||||
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
|
||||
: hover
|
||||
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
|
||||
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
|
||||
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* left accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height placeholder */}
|
||||
<div style={{ marginTop: 8, height: 22 }} />
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const navigate = useNavigate()
|
||||
@@ -27,13 +235,13 @@ export default function DashboardPage() {
|
||||
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
||||
queryKey: ['tables'],
|
||||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['orders-active'],
|
||||
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: waiters = [] } = useQuery({
|
||||
@@ -42,9 +250,14 @@ export default function DashboardPage() {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||
// waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => {
|
||||
const name = w.full_name || w.nickname || w.username
|
||||
const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
|
||||
const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
|
||||
return [w.id, { name, shortName, avatarUrl }]
|
||||
}))
|
||||
|
||||
// Build enriched table list
|
||||
const tableCards = tables.map(table => {
|
||||
const order = orders.find(o =>
|
||||
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
|
||||
@@ -82,35 +295,25 @@ export default function DashboardPage() {
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{filtered.map(({ table, order, tableStatus }) => (
|
||||
<button
|
||||
key={table.id}
|
||||
onClick={() => order && navigate(`/orders/${order.id}`)}
|
||||
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-2xl font-extrabold text-gray-800">
|
||||
{table.label || `T${table.number}`}
|
||||
</span>
|
||||
<StatusBadge status={tableStatus} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{filtered.map(({ table, order, tableStatus }) => {
|
||||
const waiterNames = order
|
||||
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
|
||||
: []
|
||||
const amount = order ? orderTotal(order.items) : null
|
||||
|
||||
{order ? (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p className="font-semibold text-gray-800">€{orderTotal(order.items)}</p>
|
||||
<p>⏱ {elapsed(order.opened_at)}</p>
|
||||
{order.waiters.length > 0 && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mt-1">—</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,36 @@ import client from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||
function submit() {
|
||||
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||||
onPrint(Number(printerId))
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<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-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Εκτυπωτής</label>
|
||||
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
|
||||
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function itemTotal(item) {
|
||||
return (item.unit_price * item.quantity).toFixed(2)
|
||||
}
|
||||
@@ -15,13 +45,58 @@ function formatDate(dt) {
|
||||
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const EVENT_LABELS = {
|
||||
ORDER_OPENED: 'Άνοιγμα',
|
||||
ITEMS_ADDED: 'Προσθήκη',
|
||||
PAYMENT: 'Πληρωμή',
|
||||
ORDER_CLOSED: 'Κλείσιμο',
|
||||
ORDER_CANCELLED: 'Ακύρωση',
|
||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||
}
|
||||
|
||||
function AuditTab({ order, waiterMap }) {
|
||||
if (!order.audit_logs || order.audit_logs.length === 0) {
|
||||
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
||||
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||
{log.amount != null && (
|
||||
<span className="ml-2 font-semibold text-green-700">€{log.amount.toFixed(2)}</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||
const { orderId: paramOrderId } = useParams()
|
||||
const orderId = propOrderId ?? paramOrderId
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ['order', orderId],
|
||||
@@ -35,6 +110,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const printOrder = useMutation({
|
||||
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||
|
||||
@@ -119,81 +206,100 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||
{isOpen && !readOnly && (
|
||||
<button
|
||||
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||
</div>
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isOpen && !readOnly && (
|
||||
{tab === 'overview' && <>
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||
{isOpen && !readOnly && (
|
||||
<button
|
||||
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||
</div>
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
{item.paid_by && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{activeItems.length > 0 && (
|
||||
{isOpen && !readOnly && activeItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||
className="btn btn-primary"
|
||||
@@ -201,19 +307,35 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
Πληρωμή όλων
|
||||
</button>
|
||||
)}
|
||||
{isOpen && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Κλείσιμο παραγγελίας
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Ακύρωση παραγγελίας
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Κλείσιμο παραγγελίας
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Ακύρωση παραγγελίας
|
||||
🖨 Εκτύπωση
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{tab === 'audit' && (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<AuditTab order={order} waiterMap={waiterMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction && (
|
||||
@@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPrintModal && printers.length > 0 && (
|
||||
<PrintOrderModal
|
||||
printers={printers}
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
onPrint={(printerId) => printOrder.mutate(printerId)}
|
||||
/>
|
||||
)}
|
||||
{showPrintModal && printers.length === 0 && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
|
||||
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
|
||||
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,40 @@ import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
|
||||
|
||||
function ZoneColorPicker({ value, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
|
||||
style={{ borderColor: !value ? '#000' : 'transparent' }}
|
||||
title="Χωρίς χρώμα"
|
||||
/>
|
||||
{ZONE_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange(c)}
|
||||
className="w-7 h-7 rounded-full border-2 transition-all"
|
||||
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TablesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [addModal, setAddModal] = useState(false)
|
||||
const [editModal, setEditModal] = useState(null)
|
||||
const [batchModal, setBatchModal] = useState(null) // group id or null
|
||||
const [batchModal, setBatchModal] = useState(null) // group object or null
|
||||
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // { id, hard }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
||||
|
||||
const { data: tables = [], isLoading } = useQuery({
|
||||
queryKey: ['tables-all', showInactive],
|
||||
@@ -29,15 +55,6 @@ export default function TablesPage() {
|
||||
}
|
||||
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
|
||||
|
||||
// Next auto-increment number within a group (or global)
|
||||
function nextNumber(groupId) {
|
||||
const relevant = groupId
|
||||
? tables.filter(t => t.group_id === groupId)
|
||||
: tables
|
||||
if (relevant.length === 0) return 1
|
||||
return Math.max(...relevant.map(t => t.number)) + 1
|
||||
}
|
||||
|
||||
const createTable = useMutation({
|
||||
mutationFn: (body) => client.post('/api/tables/', body),
|
||||
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
|
||||
@@ -70,81 +87,125 @@ export default function TablesPage() {
|
||||
mutationFn: (body) => groupModal?.id
|
||||
? client.put(`/api/tables/groups/${groupModal.id}`, body)
|
||||
: client.post('/api/tables/groups', body),
|
||||
onSuccess: () => { toast.success('Γκρουπ αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
|
||||
onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
const deleteGroup = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
|
||||
onSuccess: () => { toast.success('Γκρουπ διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
|
||||
onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
// Group tables by group
|
||||
const grouped = [
|
||||
{ group: null, tables: tables.filter(t => !t.group_id) },
|
||||
...groups.map(g => ({ group: g, tables: tables.filter(t => t.group_id === g.id) })),
|
||||
].filter(section => section.tables.length > 0 || section.group)
|
||||
// Filter tables for the active tab
|
||||
const visibleTables = activeTab === 'all'
|
||||
? tables
|
||||
: activeTab === 'ungrouped'
|
||||
? tables.filter(t => !t.group_id)
|
||||
: tables.filter(t => t.group_id === activeTab)
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
|
||||
Εμφάνιση ανενεργών
|
||||
</label>
|
||||
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέο γκρουπ</button>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button>
|
||||
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη</button>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grouped.map(({ group, tables: gt }) => (
|
||||
<div key={group?.id ?? 'ungrouped'}>
|
||||
{group && (
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="font-semibold text-gray-700">{group.name}</h2>
|
||||
<button onClick={() => setGroupModal(group)} className="text-xs text-gray-400 hover:text-gray-600">✏️</button>
|
||||
<button onClick={() => setBatchModal(group.id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
|
||||
{/* Zone tabs */}
|
||||
<div className="flex gap-1 flex-wrap border-b border-gray-200 pb-0">
|
||||
{[
|
||||
{ id: 'all', label: 'Όλα', color: null },
|
||||
...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} – ${g.name}` : g.name, color: g.color, group: g })),
|
||||
...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white border border-b-white border-gray-200 -mb-px text-primary-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tab.color && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||
{tab.label}
|
||||
<span className="ml-0.5 text-xs text-gray-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})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zone header (when viewing a specific zone) */}
|
||||
{activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
|
||||
const g = groups.find(g => g.id === activeTab)
|
||||
if (!g) return null
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">{g.name}</span>
|
||||
{g.prefix && <span className="ml-2 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!group && gt.length > 0 && <h2 className="font-semibold text-gray-500 mb-2 text-sm">Χωρίς γκρουπ</h2>}
|
||||
|
||||
<div className="card divide-y divide-gray-100">
|
||||
{gt.length === 0 && (
|
||||
<p className="px-4 py-4 text-sm text-gray-400 text-center">Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.</p>
|
||||
)}
|
||||
{gt.map(t => (
|
||||
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-40' : ''}`}>
|
||||
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
|
||||
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
|
||||
{!t.is_active && <span className="text-xs text-red-400 font-medium">Ανενεργό</span>}
|
||||
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
||||
{t.is_active
|
||||
? <button onClick={() => setConfirmDelete({ id: t.id, hard: false })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-amber-600 hover:bg-amber-50">Απενεργ.</button>
|
||||
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
|
||||
}
|
||||
<button onClick={() => setConfirmDelete({ id: t.id, hard: true })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης</button>
|
||||
<button onClick={() => setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})()}
|
||||
|
||||
{tables.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν τραπέζια. Προσθέστε ένα.</p>
|
||||
)}
|
||||
{/* Tables list */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
{visibleTables.length === 0 && (
|
||||
<p className="px-4 py-8 text-sm text-gray-400 text-center">
|
||||
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
|
||||
</p>
|
||||
)}
|
||||
{visibleTables.map((t, idx) => (
|
||||
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-50 bg-gray-50' : ''}`}>
|
||||
<span className="text-xs text-gray-400 font-mono w-6 text-right">{idx + 1}</span>
|
||||
<p className="flex-1 font-medium text-gray-800">{t.label || `Τραπέζι ${t.number}`}</p>
|
||||
{t.group && (
|
||||
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded hidden sm:inline">
|
||||
{t.group.prefix ? `${t.group.prefix}` : t.group.name}
|
||||
</span>
|
||||
)}
|
||||
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
|
||||
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία</button>
|
||||
{t.is_active
|
||||
? <button
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: false })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Απενεργ.</button>
|
||||
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
|
||||
}
|
||||
<button
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ id: t.id, hard: true })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-8 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Διαγραφή</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add single table */}
|
||||
{addModal && (
|
||||
<TableModal
|
||||
title="Νέο τραπέζι"
|
||||
initial={{ number: nextNumber(null), label: '', group_id: '' }}
|
||||
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
|
||||
groups={groups}
|
||||
onSave={(f) => createTable.mutate({ number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onClose={() => setAddModal(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -153,9 +214,9 @@ export default function TablesPage() {
|
||||
{editModal && (
|
||||
<TableModal
|
||||
title="Επεξεργασία τραπεζιού"
|
||||
initial={{ number: editModal.number, label: editModal.label || '', group_id: editModal.group_id || '' }}
|
||||
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
|
||||
groups={groups}
|
||||
onSave={(f) => updateTable.mutate({ id: editModal.id, number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -163,18 +224,17 @@ export default function TablesPage() {
|
||||
{/* Batch add */}
|
||||
{batchModal !== null && (
|
||||
<BatchModal
|
||||
groupId={batchModal}
|
||||
startNumber={nextNumber(batchModal)}
|
||||
group={batchModal}
|
||||
onSave={(body) => batchCreate.mutate(body)}
|
||||
onClose={() => setBatchModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Group form */}
|
||||
{/* Group/Zone form */}
|
||||
{groupModal !== null && (
|
||||
<GroupModal
|
||||
group={groupModal}
|
||||
onSave={(name) => saveGroup.mutate({ name })}
|
||||
onSave={(data) => saveGroup.mutate(data)}
|
||||
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
|
||||
onClose={() => setGroupModal(null)}
|
||||
/>
|
||||
@@ -204,53 +264,63 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
|
||||
<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">{title}</h2>
|
||||
<div>
|
||||
<label className="label">Αριθμός τραπεζιού *</label>
|
||||
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
|
||||
<label className="label">Όνομα τραπεζιού</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="π.χ. BS-TBL-1 ή Βεράντα 3"
|
||||
value={form.label}
|
||||
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό για αυτόματη αρίθμηση.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ετικέτα</label>
|
||||
<input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Γκρουπ</label>
|
||||
<label className="label">Ζώνη</label>
|
||||
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
|
||||
<option value="">— Χωρίς γκρουπ —</option>
|
||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
<option value="">— Χωρίς ζώνη —</option>
|
||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={() => onSave(form)} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
<button onClick={() => onSave(form)} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchModal({ groupId, startNumber, onSave, onClose }) {
|
||||
function BatchModal({ group, onSave, onClose }) {
|
||||
const [count, setCount] = useState(5)
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
|
||||
return (
|
||||
<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">
|
||||
<h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2>
|
||||
{group && <p className="text-sm text-gray-500">Ζώνη: <span className="font-medium text-gray-700">{group.name}</span></p>}
|
||||
<div>
|
||||
<label className="label">Πρόθεμα ονόματος</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="π.χ. BS-TBL- → BS-TBL-1, BS-TBL-2…"
|
||||
value={prefix}
|
||||
onChange={e => setPrefix(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πλήθος</label>
|
||||
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} autoFocus />
|
||||
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πρόθεμα ετικέτας</label>
|
||||
<input className="input" placeholder="π.χ. Out- → Out-1, Out-2…" value={prefix} onChange={e => setPrefix(e.target.value)} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Ξεκινά από αριθμό {startNumber}, δημιουργεί {count} τραπέζια.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onSave({ group_id: groupId, count, name_prefix: prefix, start_number: startNumber })}
|
||||
onClick={() => onSave({ group_id: group?.id ?? null, count, name_prefix: prefix })}
|
||||
disabled={count < 1 || !prefix.trim()}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
Δημιουργία
|
||||
Δημιουργία {count > 0 && prefix.trim() ? `(${prefix.trim()}1 … ${prefix.trim()}${count})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,18 +330,29 @@ function BatchModal({ groupId, startNumber, onSave, onClose }) {
|
||||
|
||||
function GroupModal({ group, onSave, onDelete, onClose }) {
|
||||
const [name, setName] = useState(group.name || '')
|
||||
const [prefix, setPrefix] = useState(group.prefix || '')
|
||||
const [color, setColor] = useState(group.color || null)
|
||||
return (
|
||||
<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">
|
||||
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία γκρουπ' : 'Νέο γκρουπ'}</h2>
|
||||
<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">{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}</h2>
|
||||
<div>
|
||||
<label className="label">Όνομα γκρουπ</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||||
<label className="label">Όνομα ζώνης *</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πρόθεμα (για μαζική δημιουργία)</label>
|
||||
<input className="input font-mono" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="π.χ. BS" />
|
||||
<p className="text-xs text-gray-400 mt-1">Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Χρώμα ζώνης</label>
|
||||
<ZoneColorPicker value={color} onChange={setColor} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">🗑</button>}
|
||||
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">Διαγραφή</button>}
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={() => onSave(name)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
<button onClick={() => onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
function avatarColor(name) {
|
||||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return palette[h % palette.length]
|
||||
}
|
||||
|
||||
function WaiterAvatar({ waiter, size = 40 }) {
|
||||
const displayName = waiter.full_name || waiter.nickname || waiter.username
|
||||
if (waiter.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={API_URL + waiter.avatar_url}
|
||||
alt={displayName}
|
||||
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = displayName.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(displayName),
|
||||
color: 'white', fontSize: size * 0.38, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
function PinInput({ value, onChange }) {
|
||||
@@ -30,27 +63,152 @@ function PinInput({ value, onChange }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ZoneModal({ waiter, groups, onClose }) {
|
||||
const qc = useQueryClient()
|
||||
// Derive initial state from waiter's zone_assignments
|
||||
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 [allZones, setAllZones] = useState(hasAllZones)
|
||||
const [selected, setSelected] = useState(new Set(assignedIds))
|
||||
|
||||
const saveZones = useMutation({
|
||||
mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body),
|
||||
onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
function toggleGroup(gid) {
|
||||
setSelected(prev => {
|
||||
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 (
|
||||
<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">
|
||||
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
</label>
|
||||
|
||||
{!allZones && (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
|
||||
)}
|
||||
{groups.map(g => (
|
||||
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
||||
<input
|
||||
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>
|
||||
{g.color && (
|
||||
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allZones && selected.size === 0 && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
|
||||
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function WaitersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [addModal, setAddModal] = useState(false)
|
||||
const [pinModal, setPinModal] = useState(null) // waiter id
|
||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||
const [newPin, setNewPin] = useState('')
|
||||
const [newForm, setNewForm] = useState({ username: '', pin: '', role: 'waiter' })
|
||||
const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' })
|
||||
const [editModal, setEditModal] = useState(null) // waiter object
|
||||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '' })
|
||||
const avatarInputRef = useRef(null)
|
||||
|
||||
const { data: waiters = [], isLoading } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['table-groups'],
|
||||
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||||
})
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||
|
||||
const createWaiter = useMutation({
|
||||
mutationFn: (body) => client.post('/api/waiters/', body),
|
||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', pin: '', role: 'waiter' }); invalidate() },
|
||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
const updateWaiter = useMutation({
|
||||
mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body),
|
||||
onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() },
|
||||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||
})
|
||||
|
||||
const uploadAvatar = useMutation({
|
||||
mutationFn: ({ id, file }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Avatar ανέβηκε')
|
||||
setEditModal(res.data)
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
||||
})
|
||||
|
||||
const deleteAvatar = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
||||
onSuccess: (res) => {
|
||||
toast.success('Avatar αφαιρέθηκε')
|
||||
setEditModal(res.data)
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const toggleBlock = useMutation({
|
||||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||
onSuccess: () => { invalidate() },
|
||||
@@ -72,7 +230,7 @@ export default function WaitersPage() {
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||
@@ -84,13 +242,31 @@ export default function WaitersPage() {
|
||||
)}
|
||||
{waiters.map(w => (
|
||||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||
<WaiterAvatar waiter={w} size={44} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-800">{w.username}</p>
|
||||
<p className="text-xs text-gray-500">{w.role}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
||||
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
|
||||
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
||||
{w.role === 'waiter' && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{w.zone_assignments.length === 0
|
||||
? 'Χωρίς ζώνες'
|
||||
: w.zone_assignments.some(z => z.group_id === null)
|
||||
? 'Όλες οι ζώνες'
|
||||
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
||||
</p>
|
||||
)}
|
||||
</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'}`}>
|
||||
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||
</span>
|
||||
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
|
||||
{w.role === 'waiter' && (
|
||||
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
|
||||
)}
|
||||
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
|
||||
<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'}`}>
|
||||
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
|
||||
@@ -105,9 +281,17 @@ export default function WaitersPage() {
|
||||
<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">
|
||||
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
||||
<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">Όνομα χρήστη</label>
|
||||
<input className="input" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: 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>
|
||||
@@ -123,7 +307,7 @@ export default function WaitersPage() {
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => createWaiter.mutate({ username: newForm.username, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
|
||||
disabled={!newForm.username.trim() || newForm.pin.length < 4}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
@@ -134,6 +318,76 @@ export default function WaitersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit profile modal */}
|
||||
{editModal && (
|
||||
<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">
|
||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.username}</h2>
|
||||
|
||||
{/* Avatar section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<WaiterAvatar waiter={editModal} size={64} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadAvatar.mutate({ id: editModal.id, file })
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<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 ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
|
||||
</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.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πλήρες όνομα</label>
|
||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
|
||||
</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 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 })}
|
||||
disabled={updateWaiter.isPending || !editForm.username.trim()}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset PIN modal */}
|
||||
{pinModal !== null && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
@@ -164,6 +418,10 @@ export default function WaitersPage() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{zoneModal && (
|
||||
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,40 +10,164 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const ingredients = product.ingredients || []
|
||||
const preferenceSets = product.preference_sets || []
|
||||
|
||||
const [selectedPreferences, setSelectedPreferences] = useState(
|
||||
Object.fromEntries(preferenceSets.map(ps => [ps.id, null]))
|
||||
// selectedPreferences: { [setId]: choice | null }
|
||||
const [selectedPreferences, setSelectedPreferences] = useState(() =>
|
||||
Object.fromEntries(
|
||||
preferenceSets.map(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
return [ps.id, def]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Per-preference-choice inline sub-choices: { [choiceId]: subChoice | null }
|
||||
const [selectedSubChoices, setSelectedSubChoices] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
if (def && def.sub_choices?.length > 0) {
|
||||
const subDef = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
|
||||
init[def.id] = subDef
|
||||
}
|
||||
})
|
||||
return init
|
||||
})
|
||||
|
||||
// Shared-subset selections: { [setId]: subChoice | null }
|
||||
const [selectedSharedSubs, setSelectedSharedSubs] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
if (ps.shared_subset?.choices?.length > 0) {
|
||||
const selectedChoice = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
if (!selectedChoice || !selectedChoice.disables_subset) {
|
||||
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||
init[ps.id] = subDef
|
||||
}
|
||||
}
|
||||
})
|
||||
return init
|
||||
})
|
||||
|
||||
// Option sub-choices: { [optionId]: subChoice | null }
|
||||
// Initialise with any option that has a default sub-choice pre-selected — but only
|
||||
// if the option itself is checked by default (options are all unchecked initially).
|
||||
const [selectedOptionSubs, setSelectedOptionSubs] = useState({})
|
||||
|
||||
function selectPreference(setId, choice) {
|
||||
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
|
||||
if (choice && choice.sub_choices?.length > 0) {
|
||||
const subDef = choice.sub_choices.find(s => s.is_default) ?? choice.sub_choices[0]
|
||||
setSelectedSubChoices(prev => ({ ...prev, [choice.id]: subDef }))
|
||||
}
|
||||
const ps = preferenceSets.find(p => p.id === setId)
|
||||
if (ps?.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
|
||||
setSelectedSharedSubs(prev => {
|
||||
if (prev[setId] != null) return prev
|
||||
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||
return { ...prev, [setId]: subDef }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectSubChoice(parentChoiceId, sub) {
|
||||
setSelectedSubChoices(prev => ({ ...prev, [parentChoiceId]: sub }))
|
||||
}
|
||||
|
||||
function selectSharedSub(setId, sub) {
|
||||
setSelectedSharedSubs(prev => ({ ...prev, [setId]: sub }))
|
||||
}
|
||||
|
||||
function toggleOption(opt) {
|
||||
setSelectedOptions(prev => {
|
||||
const exists = prev.find(o => o.id === opt.id)
|
||||
if (exists) return prev.filter(o => o.id !== opt.id)
|
||||
if (exists) {
|
||||
// Deselecting: also clear its sub-choice selection
|
||||
setSelectedOptionSubs(s => { const n = { ...s }; delete n[opt.id]; return n })
|
||||
return prev.filter(o => o.id !== opt.id)
|
||||
}
|
||||
// Selecting: pre-select default sub-choice if any
|
||||
if (opt.sub_choices?.length > 0) {
|
||||
const subDef = opt.sub_choices.find(s => s.is_default) ?? opt.sub_choices[0]
|
||||
setSelectedOptionSubs(s => ({ ...s, [opt.id]: subDef }))
|
||||
}
|
||||
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
|
||||
})
|
||||
}
|
||||
|
||||
function selectOptionSub(optId, sub) {
|
||||
setSelectedOptionSubs(prev => ({ ...prev, [optId]: sub }))
|
||||
}
|
||||
|
||||
function toggleIngredient(ing) {
|
||||
setRemovedIngredients(prev =>
|
||||
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
|
||||
)
|
||||
}
|
||||
|
||||
const prefExtra = Object.values(selectedPreferences).reduce((s, ch) => s + (ch?.extra_cost ?? 0), 0)
|
||||
const extraPrice = selectedOptions.reduce((s, o) => s + (o.price_delta ?? o.extra_cost ?? 0), 0) + prefExtra
|
||||
const totalPrice = (product.base_price + extraPrice) * quantity
|
||||
// Check whether any checked option with sub_choices is missing its sub-choice selection
|
||||
const optionSubsMissing = selectedOptions.some(o => {
|
||||
const full = options.find(opt => opt.id === o.id)
|
||||
return full?.sub_choices?.length > 0 && selectedOptionSubs[o.id] == null
|
||||
})
|
||||
|
||||
function isPrefSetComplete(ps) {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (choice == null) return false
|
||||
if (choice.sub_choices?.length > 0 && selectedSubChoices[choice.id] == null) return false
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && selectedSharedSubs[ps.id] == null) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const allPrefsSelected = preferenceSets.every(isPrefSetComplete)
|
||||
const unselectedPrefs = preferenceSets.filter(ps => !isPrefSetComplete(ps))
|
||||
const canAdd = allPrefsSelected && !optionSubsMissing
|
||||
|
||||
const prefExtra = preferenceSets.reduce((s, ps) => {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return s
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset)
|
||||
? (selectedSharedSubs[ps.id] ?? null) : null
|
||||
return s + (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||
}, 0)
|
||||
const optionExtra = selectedOptions.reduce((s, o) => {
|
||||
const subExtra = selectedOptionSubs[o.id]?.extra_cost ?? 0
|
||||
return s + (o.price_delta ?? 0) + subExtra
|
||||
}, 0)
|
||||
const totalPrice = (product.base_price + optionExtra + prefExtra) * quantity
|
||||
|
||||
function handleAdd() {
|
||||
const prefChoices = Object.values(selectedPreferences)
|
||||
.filter(Boolean)
|
||||
.map(ch => ({ id: ch.id, name: ch.name, price_delta: ch.extra_cost ?? 0 }))
|
||||
if (!canAdd) return
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return []
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
||||
const sharedSub = selectedSharedSubs[ps.id] ?? null
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const optionEntries = selectedOptions.flatMap(o => {
|
||||
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0 }]
|
||||
const sub = selectedOptionSubs[o.id]
|
||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
|
||||
return entries
|
||||
})
|
||||
|
||||
onAdd({
|
||||
product_id: product.id,
|
||||
quantity,
|
||||
selected_options: [...selectedOptions, ...prefChoices],
|
||||
selected_options: [...optionEntries, ...prefChoices],
|
||||
removed_ingredients: removedIngredients,
|
||||
notes,
|
||||
})
|
||||
@@ -57,51 +181,138 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
<h2 className="modal-title">{product.name}</h2>
|
||||
<p className="modal-price">{Number(totalPrice).toFixed(2)} €</p>
|
||||
|
||||
{/* ── Checkbox options with optional sub-choices ── */}
|
||||
{options.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Επιλογές</h3>
|
||||
{options.map(opt => (
|
||||
<label key={opt.id} className="modal-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedOptions.find(o => o.id === opt.id)}
|
||||
onChange={() => toggleOption(opt)}
|
||||
/>
|
||||
<span>{opt.name}</span>
|
||||
{(opt.extra_cost ?? 0) !== 0 && <span className="option-price">{(opt.extra_cost ?? 0) > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} €</span>}
|
||||
</label>
|
||||
))}
|
||||
{options.map(opt => {
|
||||
const isChecked = !!selectedOptions.find(o => o.id === opt.id)
|
||||
const hasSubs = opt.sub_choices?.length > 0
|
||||
const subMissing = isChecked && hasSubs && selectedOptionSubs[opt.id] == null
|
||||
return (
|
||||
<div key={opt.id}>
|
||||
<label className="modal-option">
|
||||
<input type="checkbox" checked={isChecked} onChange={() => toggleOption(opt)} />
|
||||
<span>{opt.name}</span>
|
||||
{(opt.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{opt.extra_cost > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
{isChecked && hasSubs && (
|
||||
<div style={{
|
||||
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||
{opt.sub_choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`optsub-${opt.id}`}
|
||||
checked={selectedOptionSubs[opt.id]?.name === sub.name}
|
||||
onChange={() => selectOptionSub(opt.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{preferenceSets.map(ps => (
|
||||
<section key={ps.id} className="modal-section">
|
||||
<h3>{ps.name}</h3>
|
||||
{ps.choices.map(ch => (
|
||||
<label key={ch.id} className="modal-option">
|
||||
<input
|
||||
type="radio"
|
||||
name={`pref-${ps.id}`}
|
||||
checked={selectedPreferences[ps.id]?.id === ch.id}
|
||||
onChange={() => selectPreference(ps.id, ch)}
|
||||
/>
|
||||
<span>{ch.name}</span>
|
||||
{(ch.extra_cost ?? 0) !== 0 && <span className="option-price">{(ch.extra_cost ?? 0) > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>}
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{/* ── Preference sets ── */}
|
||||
{preferenceSets.map(ps => {
|
||||
const missing = !isPrefSetComplete(ps)
|
||||
const selectedChoice = selectedPreferences[ps.id] ?? null
|
||||
const showSharedSubset = ps.shared_subset?.choices?.length > 0
|
||||
&& selectedChoice != null
|
||||
&& !selectedChoice.disables_subset
|
||||
const sharedMissing = showSharedSubset && selectedSharedSubs[ps.id] == null
|
||||
|
||||
return (
|
||||
<section key={ps.id} className="modal-section"
|
||||
style={missing ? { border: '1.5px solid #ef4444', borderRadius: 10, padding: '10px 12px' } : {}}>
|
||||
<h3 style={{ color: missing ? '#ef4444' : undefined }}>
|
||||
{ps.name}
|
||||
{missing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||
</h3>
|
||||
|
||||
{ps.choices.map(ch => {
|
||||
const isSelected = selectedPreferences[ps.id]?.id === ch.id
|
||||
const hasSubs = ch.sub_choices?.length > 0
|
||||
const subMissing = isSelected && hasSubs && selectedSubChoices[ch.id] == null
|
||||
return (
|
||||
<div key={ch.id}>
|
||||
<label className="modal-option">
|
||||
<input type="radio" name={`pref-${ps.id}`} checked={isSelected}
|
||||
onChange={() => selectPreference(ps.id, ch)} />
|
||||
<span>{ch.name}</span>
|
||||
{(ch.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{ch.extra_cost > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
{isSelected && hasSubs && (
|
||||
<div style={{
|
||||
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||
{ch.sub_choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`sub-${ch.id}`}
|
||||
checked={selectedSubChoices[ch.id]?.name === sub.name}
|
||||
onChange={() => selectSubChoice(ch.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showSharedSubset && (
|
||||
<div style={{
|
||||
marginTop: 8, marginLeft: 8, padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${sharedMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: sharedMissing ? '#ef4444' : '#4338ca' }}>
|
||||
{ps.shared_subset.name}
|
||||
{sharedMissing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||
</p>
|
||||
{ps.shared_subset.choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`shared-${ps.id}`}
|
||||
checked={selectedSharedSubs[ps.id]?.name === sub.name}
|
||||
onChange={() => selectSharedSub(ps.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Remove ingredients ── */}
|
||||
{ingredients.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Αφαίρεση υλικών</h3>
|
||||
{ingredients.map(ing => (
|
||||
<label key={ing.id} className="modal-option modal-option--remove">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removedIngredients.includes(ing.name)}
|
||||
onChange={() => toggleIngredient(ing)}
|
||||
/>
|
||||
<input type="checkbox" checked={removedIngredients.includes(ing.name)}
|
||||
onChange={() => toggleIngredient(ing)} />
|
||||
<span>χωρίς {ing.name}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -110,13 +321,8 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
|
||||
<section className="modal-section">
|
||||
<h3>Σημείωση</h3>
|
||||
<textarea
|
||||
className="modal-notes"
|
||||
placeholder="π.χ. χωρίς αλάτι..."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<textarea className="modal-notes" placeholder="π.χ. χωρίς αλάτι..."
|
||||
value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
|
||||
</section>
|
||||
|
||||
<div className="modal-qty">
|
||||
@@ -125,7 +331,19 @@ export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary btn--lg" onClick={handleAdd} style={{ width: '100%', marginTop: 16 }}>
|
||||
{!allPrefsSelected && (
|
||||
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 8 }}>
|
||||
Επιλέξτε: {unselectedPrefs.map(ps => ps.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{optionSubsMissing && (
|
||||
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 4 }}>
|
||||
Επιλέξτε υπο-επιλογή για τις επιλεγμένες επιλογές
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="btn btn--primary btn--lg" onClick={handleAdd} disabled={!canAdd}
|
||||
style={{ width: '100%', marginTop: 16, opacity: canAdd ? 1 : 0.45 }}>
|
||||
Προσθήκη στην παραγγελία
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,10 @@ export default function PinPad({ onSubmit, loading }) {
|
||||
const [pin, setPin] = useState('')
|
||||
|
||||
function press(digit) {
|
||||
if (pin.length < 8) setPin(p => p + digit)
|
||||
if (pin.length >= 4) return
|
||||
const next = pin + digit
|
||||
setPin(next)
|
||||
if (next.length === 4 && !loading) onSubmit(next)
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
@@ -15,7 +18,7 @@ export default function PinPad({ onSubmit, loading }) {
|
||||
if (pin.length > 0 && !loading) onSubmit(pin)
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: 8 }, (_, i) => (
|
||||
const dots = Array.from({ length: 4 }, (_, i) => (
|
||||
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}>●</span>
|
||||
))
|
||||
|
||||
|
||||
@@ -1,24 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import ItemOptionsModal from './ItemOptionsModal'
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
if (!hex) return null
|
||||
const h = hex.replace('#', '')
|
||||
const r = parseInt(h.substring(0, 2), 16)
|
||||
const g = parseInt(h.substring(2, 4), 16)
|
||||
const b = parseInt(h.substring(4, 6), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
export default function ProductPicker({ categories, products, onAdd }) {
|
||||
const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
|
||||
const [selectedProduct, setSelectedProduct] = useState(null)
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
|
||||
const filtered = products.filter(p => p.category_id === activeCat)
|
||||
|
||||
function selectCategory(id) {
|
||||
setActiveCat(id)
|
||||
setViewAllOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-tab ${activeCat === cat.id ? 'cat-tab--active' : ''}`}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
{/* View All button — always first */}
|
||||
<button
|
||||
className="cat-tab cat-tab--viewall"
|
||||
onClick={() => setViewAllOpen(true)}
|
||||
title="Εμφάνιση όλων"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
|
||||
{categories.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color
|
||||
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
|
||||
: isActive ? 'var(--accent)' : 'var(--bg3)'
|
||||
const color = cat.color
|
||||
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
|
||||
: isActive ? '#1c1400' : 'var(--muted)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="cat-tab"
|
||||
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="product-grid">
|
||||
@@ -35,6 +69,39 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All modal */}
|
||||
{viewAllOpen && (
|
||||
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
|
||||
<div
|
||||
className="cat-all-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="cat-all-modal__header">
|
||||
<span className="cat-all-modal__title">Κατηγορίες</span>
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="cat-all-grid">
|
||||
{categories.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color || 'var(--bg3)'
|
||||
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
|
||||
<span className="cat-all-tile__name">{cat.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProduct && (
|
||||
<ItemOptionsModal
|
||||
product={selectedProduct}
|
||||
|
||||
@@ -13,10 +13,11 @@ export default function TableCard({ table, order, currentUserId, onClick }) {
|
||||
cardClass = 'table-card table-card--active'
|
||||
}
|
||||
|
||||
const displayName = table.label || `T${table.number}`
|
||||
|
||||
return (
|
||||
<button className={cardClass} onClick={onClick}>
|
||||
<span className="table-card__number">{table.number}</span>
|
||||
{table.name && <span className="table-card__name">{table.name}</span>}
|
||||
<span className="table-card__number">{displayName}</span>
|
||||
<span className="table-card__status">{statusLabel}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ body { background: var(--bg); }
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
.table-card {
|
||||
display: flex;
|
||||
@@ -177,7 +177,8 @@ body { background: var(--bg); }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
min-height: 132px;
|
||||
max-height: 132px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
@@ -239,15 +240,82 @@ body { background: var(--bg); }
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
border: 2px solid transparent;
|
||||
background: var(--bg3);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.12s;
|
||||
}
|
||||
.cat-tab--active { background: var(--accent); color: #1c1400; }
|
||||
.cat-tab--viewall {
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Category All Modal ──────────────────────────────────── */
|
||||
.cat-all-modal {
|
||||
position: fixed;
|
||||
inset: 20px;
|
||||
background: var(--bg2);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cat-all-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-all-modal__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.cat-all-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.cat-all-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
.cat-all-tile--active { outline: 3px solid #fff; }
|
||||
.cat-all-tile__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cat-all-tile__name {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ── Product Grid ────────────────────────────────────────── */
|
||||
.product-picker { display: flex; flex-direction: column; flex: 1; }
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function AddItemsPage() {
|
||||
const [orderId, setOrderId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
// null = not yet sent, { allOk, results } = sent
|
||||
const [printAck, setPrintAck] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -40,11 +42,19 @@ export default function AddItemsPage() {
|
||||
if (cart.length === 0 || !orderId) return
|
||||
setSending(true)
|
||||
setError('')
|
||||
setPrintAck(null)
|
||||
try {
|
||||
await client.post(`/api/orders/${orderId}/items`, {
|
||||
const res = await client.post(`/api/orders/${orderId}/items`, {
|
||||
items: cart.map(({ _key, ...item }) => item),
|
||||
})
|
||||
navigate(`/tables/${tableId}`)
|
||||
const printResults = res.data.print_results ?? []
|
||||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||||
setPrintAck({ allOk, results: printResults })
|
||||
if (allOk) {
|
||||
// All printed fine — navigate back after a short moment
|
||||
setTimeout(() => navigate(`/tables/${tableId}`), 1200)
|
||||
}
|
||||
// If there were print failures, stay on page — waiter sees the ack panel
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
||||
} finally {
|
||||
@@ -56,6 +66,76 @@ export default function AddItemsPage() {
|
||||
return products.find(p => p.id === id)?.name || `#${id}`
|
||||
}
|
||||
|
||||
// If we have a print ack with failures, show the ack overlay instead of the normal UI
|
||||
if (printAck && !printAck.allOk) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}>←</button>
|
||||
<span className="top-bar__title">Αποτέλεσμα εκτύπωσης</span>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: 20 }}>
|
||||
<div style={{
|
||||
background: '#7f1d1d', borderRadius: 14, padding: '16px 18px',
|
||||
border: '1px solid #ef4444',
|
||||
}}>
|
||||
<p style={{ fontWeight: 700, fontSize: 16, color: '#fca5a5', marginBottom: 8 }}>
|
||||
⚠ Πρόβλημα εκτύπωσης
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: '#fca5a5' }}>
|
||||
Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
|
||||
Τα αντικείμενα παραμένουν ως "σχέδιο" — δεν έχουν σταλεί στην κουζίνα/μπαρ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{printAck.results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
background: r.success ? '#14532d' : '#7f1d1d',
|
||||
border: `1px solid ${r.success ? '#22c55e' : '#ef4444'}`,
|
||||
borderRadius: 12, padding: '12px 16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22 }}>{r.success ? '✓' : '✗'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontWeight: 600, fontSize: 15, color: r.success ? '#86efac' : '#fca5a5' }}>
|
||||
{r.printer_name}
|
||||
</p>
|
||||
{r.error && (
|
||||
<p style={{ fontSize: 12, color: '#fca5a5', marginTop: 2 }}>{r.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => navigate(`/tables/${tableId}`)}
|
||||
>
|
||||
Επιστροφή στο τραπέζι
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={async () => {
|
||||
setPrintAck(null)
|
||||
setCart([])
|
||||
navigate(`/tables/${tableId}`)
|
||||
}}
|
||||
>
|
||||
Εντάξει, συνέχεια
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
@@ -91,6 +171,17 @@ export default function AddItemsPage() {
|
||||
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
{/* Success flash when all printers OK */}
|
||||
{printAck?.allOk && (
|
||||
<div style={{
|
||||
background: '#14532d', border: '1px solid #22c55e',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
color: '#86efac', fontWeight: 600, fontSize: 14, textAlign: 'center',
|
||||
}}>
|
||||
✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
|
||||
@@ -15,11 +15,8 @@ export default function LoginPage() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ username, password: pin })
|
||||
const { data } = await client.post('/api/auth/login', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
login({ id: data.user_id, username: data.username, role: data.role }, data.access_token)
|
||||
const { data } = await client.post('/api/auth/login', { username, pin })
|
||||
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
|
||||
navigate('/tables')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
|
||||
|
||||
@@ -44,10 +44,8 @@ export default function TableDetailPage() {
|
||||
|
||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||
const allPaid = order && activeItems.length === 0
|
||||
|
||||
const isMyOrder = order && (
|
||||
order.opened_by === user?.id || order.waiters?.some(w => w.waiter_id === user?.id)
|
||||
)
|
||||
// Any waiter whose zone covers this table can interact with orders on it
|
||||
const canInteract = !!order
|
||||
|
||||
async function openOrder() {
|
||||
try {
|
||||
@@ -123,12 +121,12 @@ export default function TableDetailPage() {
|
||||
<div className="detail-body">
|
||||
<OrderSummary
|
||||
order={order}
|
||||
selectable={isMyOrder && !paying}
|
||||
selectable={canInteract && !paying}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleItem}
|
||||
/>
|
||||
|
||||
{isMyOrder && activeItems.length > 0 && (
|
||||
{canInteract && activeItems.length > 0 && (
|
||||
<div style={{ padding: '4px 12px 8px' }}>
|
||||
<button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}>
|
||||
{allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
|
||||
@@ -136,7 +134,7 @@ export default function TableDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMyOrder && (
|
||||
{canInteract && (
|
||||
<div className="action-bar">
|
||||
<button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}>
|
||||
+ Προσθήκη
|
||||
@@ -159,12 +157,6 @@ export default function TableDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMyOrder && (
|
||||
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>
|
||||
Ανάγνωση μόνο — άλλος σερβιτόρος
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import TableCard from '../components/TableCard'
|
||||
import ConnectionBanner from '../components/ConnectionBanner'
|
||||
@@ -11,9 +11,13 @@ const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύ
|
||||
export default function TableListPage() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const [tables, setTables] = useState([])
|
||||
const [groups, setGroups] = useState([])
|
||||
const [orders, setOrders] = useState([])
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [zoneOpen, setZoneOpen] = useState(false)
|
||||
const [selectedZones, setSelectedZones] = useState(new Set())
|
||||
const zoneRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,14 +26,25 @@ export default function TableListPage() {
|
||||
return () => window.removeEventListener('backend-offline', handler)
|
||||
}, [])
|
||||
|
||||
// Close zone dropdown on outside click
|
||||
useEffect(() => {
|
||||
function onClick(e) {
|
||||
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [tablesRes, ordersRes] = await Promise.all([
|
||||
const [tablesRes, ordersRes, groupsRes] = await Promise.all([
|
||||
client.get('/api/tables/'),
|
||||
client.get('/api/orders/my'),
|
||||
client.get('/api/tables/groups'),
|
||||
])
|
||||
setTables(tablesRes.data)
|
||||
setOrders(ordersRes.data)
|
||||
setGroups(groupsRes.data)
|
||||
setOffline(false)
|
||||
} catch {}
|
||||
}
|
||||
@@ -40,10 +55,19 @@ export default function TableListPage() {
|
||||
return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
|
||||
}
|
||||
|
||||
function toggleZone(id) {
|
||||
setSelectedZones(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = tables.filter(t => {
|
||||
const order = getOrder(t.id)
|
||||
if (filter === 'free') return !order
|
||||
if (filter === 'mine') return order && order.waiters?.some(w => w.waiter_id === user?.id)
|
||||
if (filter === 'free' && order) return false
|
||||
if (filter === 'mine' && !(order && order.waiters?.some(w => w.waiter_id === user?.id))) return false
|
||||
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -52,6 +76,8 @@ export default function TableListPage() {
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const zoneActive = selectedZones.size > 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
@@ -68,6 +94,66 @@ export default function TableListPage() {
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Zone filter */}
|
||||
<div ref={zoneRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
|
||||
onClick={() => setZoneOpen(o => !o)}
|
||||
>
|
||||
Ζώνη{zoneActive ? ` (${selectedZones.size})` : ''}
|
||||
</button>
|
||||
{zoneOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '110%', right: 0, zIndex: 100,
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', minWidth: 180, padding: 8,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSelectedZones(new Set())}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.size === 0 ? '#fff' : '#374151',
|
||||
background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Όλες οι ζώνες
|
||||
</button>
|
||||
{groups.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
onClick={() => toggleZone(g.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has(g.id) ? '#fff' : '#374151',
|
||||
background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{g.color && <span style={{ width: 12, height: 12, borderRadius: '50%', background: g.color, display: 'inline-block', flexShrink: 0 }} />}
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
{tables.some(t => !t.group_id) && (
|
||||
<button
|
||||
onClick={() => toggleZone('none')}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '12px 14px', borderRadius: 8, fontSize: 15,
|
||||
color: selectedZones.has('none') ? '#fff' : '#374151',
|
||||
background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Χωρίς ζώνη
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-grid">
|
||||
|
||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
runtimeCaching: [],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user