Overhaul of the frontend on waiters, orders, and payment events
Manager Dashboard: product reorder/bulk actions, preference sub-choices UI, expanded reports with DateInput component, waiter management updates, order detail improvements, Docker config and backend dockerignore added. Backend: table groups, auto-numbering, has_active_order flag, expanded reporting endpoints, waiter zone management, user schema updates, system router additions, table router fixes. Waiter PWA: TableDetailPage order/payment event improvements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ services:
|
|||||||
- ./local_backend/license_state.json:/app/license_state.json
|
- ./local_backend/license_state.json:/app/license_state.json
|
||||||
- ./logo.png:/app/logo.png:ro
|
- ./logo.png:/app/logo.png:ro
|
||||||
- ./data/product_images:/app/data/product_images
|
- ./data/product_images:/app/data/product_images
|
||||||
|
- ./data/avatars:/app/data/avatars
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "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,11 +9,11 @@ from middleware.license_check import LicenseCheckMiddleware
|
|||||||
from services.cloud_sync import start_cloud_sync
|
from services.cloud_sync import start_cloud_sync
|
||||||
|
|
||||||
# Import all models so SQLAlchemy can create their tables
|
# 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.table # noqa: F401
|
||||||
import models.printer # noqa: F401
|
import models.printer # noqa: F401
|
||||||
import models.product # 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
|
from routers import auth, tables, products, orders, waiters, reports, system
|
||||||
|
|
||||||
@@ -36,6 +36,45 @@ def _run_migrations():
|
|||||||
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
|
"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_preference_sets ADD COLUMN shared_subset TEXT",
|
||||||
"ALTER TABLE product_options ADD COLUMN sub_choices 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:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -70,6 +109,11 @@ IMAGE_DIR = "/app/data/product_images"
|
|||||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||||
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
|
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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
|
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
|
||||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class Order(Base):
|
|||||||
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||||
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class OrderWaiter(Base):
|
class OrderWaiter(Base):
|
||||||
@@ -52,9 +54,15 @@ class OrderItem(Base):
|
|||||||
added_at = Column(DateTime, default=datetime.utcnow)
|
added_at = Column(DateTime, default=datetime.utcnow)
|
||||||
printed = Column(Boolean, default=False, nullable=False)
|
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")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
added_by_user = relationship("User", 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):
|
class PrintLog(Base):
|
||||||
@@ -70,3 +78,44 @@ class PrintLog(Base):
|
|||||||
|
|
||||||
order = relationship("Order", back_populates="print_logs")
|
order = relationship("Order", back_populates="print_logs")
|
||||||
printer = relationship("Printer", 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")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TableGroup(Base):
|
|||||||
color = Column(String, nullable=True)
|
color = Column(String, nullable=True)
|
||||||
|
|
||||||
tables = relationship("Table", back_populates="group")
|
tables = relationship("Table", back_populates="group")
|
||||||
|
waiter_zones = relationship("WaiterZone", back_populates="group")
|
||||||
|
|
||||||
|
|
||||||
class Table(Base):
|
class Table(Base):
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ class User(Base):
|
|||||||
pin_hash = Column(String, nullable=False)
|
pin_hash = Column(String, nullable=False)
|
||||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||||
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")
|
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||||
|
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
|
||||||
|
|
||||||
primary_assignments = relationship(
|
primary_assignments = relationship(
|
||||||
"AssistantAssignment",
|
"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):
|
class AssistantAssignment(Base):
|
||||||
__tablename__ = "assistant_assignments"
|
__tablename__ = "assistant_assignments"
|
||||||
|
|
||||||
|
|||||||
@@ -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.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from database import get_db
|
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.user import User
|
||||||
from models.table import Table
|
from models.table import Table
|
||||||
|
from models.printer import Printer
|
||||||
from schemas.order import OrderOut
|
from schemas.order import OrderOut
|
||||||
from schemas.table import TableOut
|
from schemas.table import TableOut
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager
|
||||||
|
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shift")
|
@router.get("/shift")
|
||||||
def shift_summary(
|
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"),
|
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||||
waiter_id: Optional[int] = None,
|
waiter_id: Optional[int] = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
|
"""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()
|
target = report_date or date.today()
|
||||||
start = datetime.combine(target, datetime.min.time())
|
start = datetime.combine(target, datetime.min.time())
|
||||||
end = start + timedelta(days=1)
|
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:
|
if waiter_id:
|
||||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
q = q.filter(OrderItem.paid_by == waiter_id)
|
||||||
orders = q.all()
|
items = q.all()
|
||||||
|
|
||||||
summary = {}
|
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||||
for order in orders:
|
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
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
|
|
||||||
|
|
||||||
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])
|
@router.get("/orders/history", response_model=List[OrderOut])
|
||||||
@@ -52,6 +173,7 @@ def order_history(
|
|||||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||||
waiter_id: Optional[int] = None,
|
waiter_id: Optional[int] = None,
|
||||||
order_status: Optional[str] = Query(default=None, alias="status"),
|
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||||
|
table_id: Optional[int] = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -66,6 +188,8 @@ def order_history(
|
|||||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
if order_status:
|
if order_status:
|
||||||
q = q.filter(Order.status == 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()
|
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,
|
"order_id": active_order.id if active_order else None,
|
||||||
})
|
})
|
||||||
return result
|
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")
|
@router.post("/printers/test")
|
||||||
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
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()
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models.table import Table, TableGroup
|
from models.table import Table, TableGroup
|
||||||
from models.order import Order
|
from models.order import Order
|
||||||
from models.user import User
|
from models.user import User, WaiterZone
|
||||||
from schemas.table import (
|
from schemas.table import (
|
||||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||||
@@ -69,6 +69,19 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
|||||||
q = db.query(Table)
|
q = db.query(Table)
|
||||||
if not include_inactive:
|
if not include_inactive:
|
||||||
q = q.filter(Table.is_active == True)
|
q = q.filter(Table.is_active == True)
|
||||||
|
|
||||||
|
# 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()
|
tables = q.order_by(Table.group_id, Table.number).all()
|
||||||
|
|
||||||
active_table_ids = {
|
active_table_ids = {
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.user import User, AssistantAssignment
|
from models.user import User, AssistantAssignment, WaiterZone
|
||||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut
|
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager
|
||||||
|
|
||||||
router = APIRouter()
|
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])
|
@router.get("/", response_model=List[UserOut])
|
||||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
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)
|
@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)):
|
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()
|
waiter = _waiter_or_404(waiter_id, db)
|
||||||
if not waiter:
|
|
||||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
||||||
for field, value in body.model_dump(exclude_none=True).items():
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
setattr(waiter, field, value)
|
setattr(waiter, field, value)
|
||||||
db.commit()
|
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")
|
@router.put("/{waiter_id}/reset-pin")
|
||||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
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()
|
waiter = _waiter_or_404(waiter_id, db)
|
||||||
if not waiter:
|
|
||||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
||||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "pin reset"}
|
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")
|
@router.put("/{waiter_id}/block")
|
||||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
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()
|
waiter = _waiter_or_404(waiter_id, db)
|
||||||
if not waiter:
|
|
||||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
||||||
waiter.is_active = not waiter.is_active
|
waiter.is_active = not waiter.is_active
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"is_active": waiter.is_active}
|
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)
|
@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)):
|
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()
|
waiter = _waiter_or_404(waiter_id, db)
|
||||||
if not waiter:
|
|
||||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
||||||
db.delete(waiter)
|
db.delete(waiter)
|
||||||
db.commit()
|
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)
|
@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)):
|
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
existing = db.query(AssistantAssignment).filter(
|
existing = db.query(AssistantAssignment).filter(
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
role: str
|
role: str
|
||||||
is_active: bool = True
|
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):
|
class UserCreate(UserBase):
|
||||||
@@ -17,15 +21,35 @@ class UserUpdate(BaseModel):
|
|||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
is_active: Optional[bool] = 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):
|
class UserOut(UserBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
zone_assignments: List[WaiterZoneOut] = []
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class AssistantAssignmentOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
primary_waiter_id: int
|
primary_waiter_id: int
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>POS Manager</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import client from '../api/client'
|
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 FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||||
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||||
|
|
||||||
function elapsed(openedAt) {
|
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||||
const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
const COLORS = {
|
||||||
if (diff < 60) return `${diff}λ`
|
open: {
|
||||||
return `${Math.floor(diff / 60)}ω ${diff % 60}λ`
|
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 = []) {
|
function orderTotal(items = []) {
|
||||||
return items
|
return items
|
||||||
.filter(i => i.status !== 'cancelled')
|
.filter(i => i.status !== 'cancelled')
|
||||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
.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() {
|
export default function DashboardPage() {
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -27,13 +235,13 @@ export default function DashboardPage() {
|
|||||||
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
||||||
queryKey: ['tables'],
|
queryKey: ['tables'],
|
||||||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||||
queryKey: ['orders-active'],
|
queryKey: ['orders-active'],
|
||||||
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: waiters = [] } = useQuery({
|
const { data: waiters = [] } = useQuery({
|
||||||
@@ -42,9 +250,14 @@ export default function DashboardPage() {
|
|||||||
staleTime: 60_000,
|
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 tableCards = tables.map(table => {
|
||||||
const order = orders.find(o =>
|
const order = orders.find(o =>
|
||||||
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
|
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>
|
<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">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||||
{filtered.map(({ table, order, tableStatus }) => (
|
{filtered.map(({ table, order, tableStatus }) => {
|
||||||
<button
|
const waiterNames = order
|
||||||
key={table.id}
|
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
|
||||||
onClick={() => order && navigate(`/orders/${order.id}`)}
|
: []
|
||||||
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`}
|
const amount = order ? orderTotal(order.items) : null
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{order ? (
|
return (
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<TableCardV1
|
||||||
<p className="font-semibold text-gray-800">€{orderTotal(order.items)}</p>
|
key={table.id}
|
||||||
<p>⏱ {elapsed(order.opened_at)}</p>
|
name={table.label || `T${table.number}`}
|
||||||
{order.waiters.length > 0 && (
|
status={tableStatus}
|
||||||
<p className="text-xs text-gray-500 truncate">
|
amount={amount}
|
||||||
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')}
|
openedAt={order?.opened_at ?? null}
|
||||||
</p>
|
waiters={waiterNames}
|
||||||
)}
|
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||||
</div>
|
/>
|
||||||
) : (
|
)
|
||||||
<p className="text-sm text-gray-400 mt-1">—</p>
|
})}
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import client from '../api/client'
|
|||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
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) {
|
function itemTotal(item) {
|
||||||
return (item.unit_price * item.quantity).toFixed(2)
|
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' })
|
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 }) {
|
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||||
const { orderId: paramOrderId } = useParams()
|
const { orderId: paramOrderId } = useParams()
|
||||||
const orderId = propOrderId ?? paramOrderId
|
const orderId = propOrderId ?? paramOrderId
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState('overview')
|
||||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||||
|
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||||
|
|
||||||
const { data: order, isLoading } = useQuery({
|
const { data: order, isLoading } = useQuery({
|
||||||
queryKey: ['order', orderId],
|
queryKey: ['order', orderId],
|
||||||
@@ -35,6 +110,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
staleTime: 60_000,
|
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 waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
|
||||||
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||||
|
|
||||||
@@ -119,6 +206,20 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
{tab === 'overview' && <>
|
||||||
{/* Waiters */}
|
{/* Waiters */}
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||||
@@ -165,6 +266,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
<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>}
|
{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>
|
<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>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<StatusBadge status={item.status} />
|
<StatusBadge status={item.status} />
|
||||||
@@ -191,9 +298,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{isOpen && !readOnly && (
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{activeItems.length > 0 && (
|
{isOpen && !readOnly && activeItems.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -201,6 +307,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
Πληρωμή όλων
|
Πληρωμή όλων
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isOpen && !readOnly && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -213,6 +321,20 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
>
|
>
|
||||||
Ακύρωση παραγγελίας
|
Ακύρωση παραγγελίας
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPrintModal(true)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
🖨 Εκτύπωση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<AuditTab order={order} waiterMap={waiterMap} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
onCancel={() => setConfirmAction(null)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import { DateTimeInput } from '../components/DateInput'
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
return new Date().toISOString().slice(0, 10)
|
return new Date().toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
function todayStart() { return today() + 'T00:00' }
|
||||||
|
function todayEnd() { return today() + 'T23:59' }
|
||||||
|
|
||||||
|
function fmtDt(dt) {
|
||||||
|
if (!dt) return '—'
|
||||||
|
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
function csvDownload(rows, filename) {
|
function csvDownload(rows, filename) {
|
||||||
const header = Object.keys(rows[0]).join(',')
|
const header = Object.keys(rows[0]).join(',')
|
||||||
@@ -18,128 +26,760 @@ function csvDownload(rows, filename) {
|
|||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
// ── Shared label class for consistent toolbar height ─────────────────────────
|
||||||
const [tab, setTab] = useState('shift')
|
// All toolbar controls use h-10 so they align with date inputs
|
||||||
const [shiftDate, setShiftDate] = useState(today())
|
|
||||||
const [historyFilters, setHistoryFilters] = useState({ from: today(), to: today(), status: '' })
|
const CTRL = 'h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500'
|
||||||
|
const SELECT = CTRL + ' pr-8 appearance-none bg-[url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="%236b7280"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>\')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat'
|
||||||
|
const BTN_SEC = 'h-10 px-4 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap'
|
||||||
|
const BTN_PRI = 'h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors whitespace-nowrap'
|
||||||
|
|
||||||
|
// ── Shared modal close hook (ESC + click-outside) ────────────────────────────
|
||||||
|
|
||||||
|
function useModalClose(onClose) {
|
||||||
|
const overlayRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const onOverlayClick = useCallback((e) => {
|
||||||
|
if (e.target === overlayRef.current) onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return { overlayRef, onOverlayClick }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Print Modal (waiter / printer reports) ────────────────────────────────────
|
||||||
|
|
||||||
|
function PrintModal({ title, onClose, onPrint, printers, defaultFrom, defaultTo }) {
|
||||||
|
const [mode, setMode] = useState('simple')
|
||||||
|
const [fromDt, setFromDt] = useState(defaultFrom || todayStart())
|
||||||
|
const [toDt, setToDt] = useState(defaultTo || todayEnd())
|
||||||
|
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||||||
|
onPrint({ mode, fromDt, toDt, printerId: Number(printerId) })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div ref={overlayRef} onClick={onOverlayClick} 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-md p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Τύπος εκτύπωσης</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setMode('simple')} className={`flex-1 ${mode === 'simple' ? BTN_PRI : BTN_SEC}`}>Απλή</button>
|
||||||
|
<button onClick={() => setMode('extensive')} className={`flex-1 ${mode === 'extensive' ? BTN_PRI : BTN_SEC}`}>Αναλυτική</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||||
|
<DateTimeInput className={CTRL + ' w-full'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||||
|
<DateTimeInput className={CTRL + ' w-full'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
|
||||||
|
<select className={SELECT + ' 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 pt-1">
|
||||||
|
<button onClick={onClose} className={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
|
||||||
|
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Printer select modal (single order) ───────────────────────────────────────
|
||||||
|
|
||||||
|
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||||
|
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
function submit() {
|
||||||
|
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||||||
|
onPrint(Number(printerId)); onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} 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="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Εκτυπωτής</label>
|
||||||
|
<select className={SELECT + ' 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={`flex-1 ${BTN_SEC}`}>Ακύρωση</button>
|
||||||
|
<button onClick={submit} className={`flex-1 ${BTN_PRI}`}>Εκτύπωση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No-printers fallback modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NoPrintersModal({ onClose }) {
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<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={onClose} className={`w-full ${BTN_SEC}`}>Κλείσιμο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Printer Details Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PrinterDetailsModal({ printerRow, tableMap, onClose }) {
|
||||||
|
const orderData = printerRow?.order_data ?? []
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} 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-2xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">Εκτυπωτής: {printerRow?.printer_name}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||||||
|
<span><span className="text-gray-500">Εργασίες:</span> <strong>{printerRow?.print_jobs}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{printerRow?.orders}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{printerRow?.items}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">€{printerRow?.total?.toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{orderData.length === 0 && (
|
||||||
|
<p className="text-gray-400 p-6 text-center">Δεν υπάρχουν αναλυτικά δεδομένα.<br/><span className="text-xs">Χρησιμοποιήστε εκτύπωση Αναλυτική για πλήρη λεπτομέρεια.</span></p>
|
||||||
|
)}
|
||||||
|
{orderData.length > 0 && (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{orderData.map((od, i) => (
|
||||||
|
<div key={i} className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
{od.time} — {tableMap[od.table_id] ?? od.table}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-primary-700">€{od.total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{od.items?.length > 0 && (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{od.items.map((item, j) => (
|
||||||
|
<li key={j} className="text-sm text-gray-600 pl-4">
|
||||||
|
{item.quantity} × {item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100">
|
||||||
|
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payers Modal (multi-waiter payment breakdown) ────────────────────────────
|
||||||
|
|
||||||
|
function PayersModal({ order, waiterMap, onClose }) {
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
const paidItems = order.items.filter(i => i.status === 'paid' && i.paid_by)
|
||||||
|
|
||||||
|
// Group items by (waiter_id, paid_at rounded to minute) to create payment groups
|
||||||
|
const groups = {}
|
||||||
|
for (const item of paidItems) {
|
||||||
|
const key = `${item.paid_by}__${item.paid_at}`
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = { waiter_id: item.paid_by, paid_at: item.paid_at, payment_method: item.payment_method, items: [] }
|
||||||
|
}
|
||||||
|
groups[key].items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentGroups = Object.values(groups).sort((a, b) => (a.paid_at || '') < (b.paid_at || '') ? -1 : 1)
|
||||||
|
|
||||||
|
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} 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-lg max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">Πληρωμές — Παραγγελία #{order.id}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||||||
|
{paymentGroups.map((g, i) => {
|
||||||
|
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
|
||||||
|
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-800">{fmtDt(g.paid_at)}</span>
|
||||||
|
<span className="mx-2 text-gray-400">·</span>
|
||||||
|
<span className="text-primary-700 font-medium">{waiterName}</span>
|
||||||
|
{g.payment_method && (
|
||||||
|
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{pmLabel(g.payment_method)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-800">€{groupTotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="pl-4 space-y-0.5">
|
||||||
|
{g.items.map(it => (
|
||||||
|
<li key={it.id} className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
|
||||||
|
<span>€{(it.unit_price * it.quantity).toFixed(2)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100">
|
||||||
|
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order Details Modal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPrint }) {
|
||||||
|
const [showPrint, setShowPrint] = useState(false)
|
||||||
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
if (!order) return null
|
||||||
|
|
||||||
|
const activeItems = order.items.filter(i => i.status !== 'cancelled')
|
||||||
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
|
||||||
|
// Per-waiter subtotals for paid items
|
||||||
|
const waiterTotals = {}
|
||||||
|
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
|
||||||
|
const wid = item.paid_by
|
||||||
|
if (!waiterTotals[wid]) waiterTotals[wid] = 0
|
||||||
|
waiterTotals[wid] += item.unit_price * item.quantity
|
||||||
|
}
|
||||||
|
const waiterTotalEntries = Object.entries(waiterTotals)
|
||||||
|
|
||||||
|
const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} 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-5xl max-h-[90vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">Παραγγελία #{order.id}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||||||
|
<span><span className="text-gray-500">Τραπέζι:</span> <strong>{tableMap[order.table_id] || `#${order.table_id}`}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Ανοίχτηκε:</span> <strong>{fmtDt(order.opened_at)}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Έκλεισε:</span> <strong>{fmtDt(order.closed_at)}</strong></span>
|
||||||
|
<span><StatusBadge status={order.status} /></span>
|
||||||
|
{order.notes && <span className="w-full text-gray-500 italic">"{order.notes}"</span>}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Προϊόν</th>
|
||||||
|
<th className="text-center px-4 py-3 font-semibold text-gray-600">Ποσ.</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold text-gray-600">Τιμή/τμχ</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατ.</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τύπος</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{order.items.map(item => (
|
||||||
|
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
|
||||||
|
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
|
||||||
|
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-gray-700">€{item.unit_price.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{(item.unit_price * item.quantity).toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2.5"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600">{item.paid_by ? (waiterMap[item.paid_by] || `#${item.paid_by}`) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||||
|
{waiterTotalEntries.length > 1 && waiterTotalEntries.map(([wid, wTotal]) => (
|
||||||
|
<tr key={wid} className="text-sm">
|
||||||
|
<td colSpan={3} className="px-4 py-2 text-gray-600">Σύνολο — {waiterMap[wid] || `#${wid}`}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">€{wTotal.toFixed(2)}</td>
|
||||||
|
<td colSpan={4} />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||||
|
<td className="px-4 py-3 text-right font-bold text-primary-700">€{total.toFixed(2)}</td>
|
||||||
|
<td colSpan={4} />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 flex gap-3">
|
||||||
|
{printers.length > 0 && (
|
||||||
|
<button onClick={() => setShowPrint(true)} className={BTN_SEC}>🖨 Εκτύπωση</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showPrint && (
|
||||||
|
<PrintOrderModal
|
||||||
|
printers={printers}
|
||||||
|
onClose={() => setShowPrint(false)}
|
||||||
|
onPrint={(printerId) => { onPrint(order.id, printerId); setShowPrint(false) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const [tab, setTab] = useState('shift')
|
||||||
|
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
|
||||||
|
['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
|
||||||
|
['printers', 'Σύνοψη εκτυπωτών'],
|
||||||
|
['history', 'Ιστορικό παραγγελιών'],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Αναφορές</h1>
|
<h1 className="text-xl font-bold text-gray-800">Αναφορές</h1>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button onClick={() => setTab('shift')} className={`btn ${tab === 'shift' ? 'btn-primary' : 'btn-secondary'}`}>Σύνοψη βάρδιας</button>
|
{TABS.map(([key, label]) => (
|
||||||
<button onClick={() => setTab('history')} className={`btn ${tab === 'history' ? 'btn-primary' : 'btn-secondary'}`}>Ιστορικό παραγγελιών</button>
|
<button key={key} onClick={() => setTab(key)} className={`btn ${tab === key ? 'btn-primary' : 'btn-secondary'}`}>{label}</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'shift' && <ShiftTab date={shiftDate} setDate={setShiftDate} />}
|
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||||||
|
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||||||
|
{tab === 'printers' && <PrintersTab />}
|
||||||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftTab({ date, setDate }) {
|
// ── Shift / Waiter Totals Tab ─────────────────────────────────────────────────
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['report-shift', date],
|
function WaiterShiftDetailsModal({ row, tableMap, onClose }) {
|
||||||
queryFn: () => client.get(`/api/reports/shift?date=${date}`).then(r => r.data),
|
const { overlayRef, onOverlayClick } = useModalClose(onClose)
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef} onClick={onOverlayClick} 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-2xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">Σερβιτόρος: {row.waiter_name}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 px-6 py-3 bg-gray-50 border-b border-gray-100 text-sm">
|
||||||
|
<span><span className="text-gray-500">Παραγγελίες:</span> <strong>{row.orders}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Αντικείμενα:</span> <strong>{row.items}</strong></span>
|
||||||
|
<span><span className="text-gray-500">Σύνολο:</span> <strong className="text-primary-700">€{row.total.toFixed(2)}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{(row.order_data ?? []).length === 0 && (
|
||||||
|
<p className="text-gray-400 p-6 text-center">Δεν βρέθηκαν παραγγελίες.</p>
|
||||||
|
)}
|
||||||
|
{(row.order_data ?? []).length > 0 && (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Άνοιγμα</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κλείσιμο</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{row.order_data.map(od => (
|
||||||
|
<tr key={od.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-500">{od.id}</td>
|
||||||
|
<td className="px-4 py-2 font-medium text-gray-800">{od.table}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{od.time_open}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{od.time_close || '—'}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-semibold text-gray-800">€{od.total.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100">
|
||||||
|
<button onClick={onClose} className={BTN_SEC}>Κλείσιμο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftTab({ endpoint, title }) {
|
||||||
|
const [fromDt, setFromDt] = useState(todayStart())
|
||||||
|
const [toDt, setToDt] = useState(todayEnd())
|
||||||
|
const [printTarget, setPrintTarget] = useState(null) // waiter row
|
||||||
|
const [detailTarget, setDetailTarget] = useState(null) // waiter row
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['report-shift', endpoint, fromDt, toDt],
|
||||||
|
queryFn: () => client.get(`${endpoint}?from=${encodeURIComponent(fromDt)}&to=${encodeURIComponent(toDt)}`).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = data
|
const { data: printers = [] } = useQuery({
|
||||||
? Object.entries(data.waiters).map(([name, s]) => ({
|
queryKey: ['printers'],
|
||||||
Σερβιτόρος: name,
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
Παραγγελίες: s.orders,
|
staleTime: 60_000,
|
||||||
'Αντικείμενα': s.items,
|
})
|
||||||
'Σύνολο (€)': s.total.toFixed(2),
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
|
|
||||||
const grandTotal = rows.reduce((s, r) => s + parseFloat(r['Σύνολο (€)']), 0)
|
const { data: tables = [] } = useQuery({
|
||||||
|
queryKey: ['tables'],
|
||||||
|
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||||||
|
|
||||||
|
const printMutation = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/reports/print/waiter', body),
|
||||||
|
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||||
|
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// New API returns { waiters: [{waiter_id, waiter_name, orders, items, total, order_data}] }
|
||||||
|
const rows = data?.waiters ?? []
|
||||||
|
|
||||||
|
const grandTotal = rows.reduce((s, r) => s + r.total, 0)
|
||||||
|
const grandOrders = rows.reduce((s, r) => s + r.orders, 0)
|
||||||
|
const grandItems = rows.reduce((s, r) => s + r.items, 0)
|
||||||
|
|
||||||
|
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
|
||||||
|
printMutation.mutate({ waiter_name: printTarget.waiter_name, printer_id: printerId, mode, from_dt: fd, to_dt: td })
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvRows = rows.map(r => ({
|
||||||
|
Σερβιτόρος: r.waiter_name,
|
||||||
|
Παραγγελίες: r.orders,
|
||||||
|
Αντικείμενα: r.items,
|
||||||
|
'Σύνολο (€)': r.total.toFixed(2),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Ημερομηνία</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||||
<input type="date" className="input w-44" value={date} onChange={e => setDate(e.target.value)} />
|
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||||
|
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<button
|
<button onClick={() => csvDownload(csvRows, `shift_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||||||
onClick={() => csvDownload(rows, `shift_${date}.csv`)}
|
|
||||||
className="btn btn-secondary self-end"
|
|
||||||
>
|
|
||||||
Εξαγωγή CSV
|
Εξαγωγή CSV
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||||
|
|
||||||
{!isLoading && rows.length === 0 && (
|
{!isLoading && rows.length === 0 && (
|
||||||
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.</p>
|
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b border-gray-100">
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
{Object.keys(rows[0]).map(h => (
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||||||
<th key={h} className="text-left px-4 py-3 font-semibold text-gray-600">{h}</th>
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||||||
))}
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο (€)</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<tr key={i} className="hover:bg-gray-50">
|
<tr key={i} className="hover:bg-gray-50">
|
||||||
{Object.values(r).map((v, j) => (
|
<td className="px-4 py-3 font-medium text-gray-800">{r.waiter_name}</td>
|
||||||
<td key={j} className="px-4 py-3 text-gray-800">{v}</td>
|
<td className="px-4 py-3 text-gray-700">{r.orders}</td>
|
||||||
))}
|
<td className="px-4 py-3 text-gray-700">{r.items}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-800">€{r.total.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
|
||||||
|
<button onClick={() => setPrintTarget(r)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||||
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Παραγγελίες'], 0)}</td>
|
<td className="px-4 py-3 font-bold">{grandOrders}</td>
|
||||||
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r['Αντικείμενα'], 0)}</td>
|
<td className="px-4 py-3 font-bold">{grandItems}</td>
|
||||||
<td className="px-4 py-3 font-bold text-primary-700">€{grandTotal.toFixed(2)}</td>
|
<td className="px-4 py-3 font-bold text-primary-700">€{grandTotal.toFixed(2)}</td>
|
||||||
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{printTarget && printers.length > 0 && (
|
||||||
|
<PrintModal
|
||||||
|
title={`Εκτύπωση: ${printTarget.waiter_name}`}
|
||||||
|
printers={printers}
|
||||||
|
defaultFrom={fromDt}
|
||||||
|
defaultTo={toDt}
|
||||||
|
onClose={() => setPrintTarget(null)}
|
||||||
|
onPrint={handlePrint}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{printTarget && printers.length === 0 && <NoPrintersModal onClose={() => setPrintTarget(null)} />}
|
||||||
|
|
||||||
|
{detailTarget && (
|
||||||
|
<WaiterShiftDetailsModal
|
||||||
|
row={detailTarget}
|
||||||
|
tableMap={tableMap}
|
||||||
|
onClose={() => setDetailTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryTab({ filters, setFilters }) {
|
// ── Printer Totals Tab ────────────────────────────────────────────────────────
|
||||||
const navigate = useNavigate()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
const params = new URLSearchParams({ from: filters.from, to: filters.to + 'T23:59:59', page })
|
function PrintersTab() {
|
||||||
|
const [fromDt, setFromDt] = useState(todayStart())
|
||||||
|
const [toDt, setToDt] = useState(todayEnd())
|
||||||
|
const [printTarget, setPrintTarget] = useState(null) // printer_id
|
||||||
|
const [detailTarget, setDetailTarget] = useState(null) // full printer row object
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ from: fromDt, to: toDt })
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['report-printers', fromDt, toDt],
|
||||||
|
queryFn: () => client.get(`/api/reports/printers?${params}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: printers = [] } = useQuery({
|
||||||
|
queryKey: ['printers'],
|
||||||
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: tables = [] } = useQuery({
|
||||||
|
queryKey: ['tables'],
|
||||||
|
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||||||
|
|
||||||
|
const printMutation = useMutation({
|
||||||
|
mutationFn: (body) => client.post('/api/reports/print/printer', body),
|
||||||
|
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||||
|
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = data?.printers ?? []
|
||||||
|
|
||||||
|
function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) {
|
||||||
|
printMutation.mutate({ printer_target_id: printTarget, printer_id: printerId, mode, from_dt: fd, to_dt: td })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||||
|
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||||
|
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||||
|
{!isLoading && rows.length === 0 && (
|
||||||
|
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν δεδομένα για αυτό το διάστημα.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εκτυπωτής</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Εργασίες</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Αντικείμενα</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σύνολο (€)</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-800">{r.printer_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{r.print_jobs}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{r.orders}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{r.items}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold text-gray-800">€{r.total.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setDetailTarget(r)} className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7">Λεπτομέρειες</button>
|
||||||
|
<button onClick={() => setPrintTarget(r.printer_id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">🖨 Εκτύπωση</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||||
|
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.print_jobs, 0)}</td>
|
||||||
|
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.orders, 0)}</td>
|
||||||
|
<td className="px-4 py-3 font-bold">{rows.reduce((s, r) => s + r.items, 0)}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-primary-700">€{rows.reduce((s, r) => s + r.total, 0).toFixed(2)}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{printTarget !== null && printers.length > 0 && (
|
||||||
|
<PrintModal
|
||||||
|
title={`Εκτύπωση: ${rows.find(r => r.printer_id === printTarget)?.printer_name ?? 'Εκτυπωτής'}`}
|
||||||
|
printers={printers}
|
||||||
|
defaultFrom={fromDt}
|
||||||
|
defaultTo={toDt}
|
||||||
|
onClose={() => setPrintTarget(null)}
|
||||||
|
onPrint={handlePrint}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTarget && (
|
||||||
|
<PrinterDetailsModal
|
||||||
|
printerRow={detailTarget}
|
||||||
|
tableMap={tableMap}
|
||||||
|
onClose={() => setDetailTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order History Tab ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function HistoryTab({ filters, setFilters }) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [detailOrder, setDetailOrder] = useState(null) // full order object for modal
|
||||||
|
const [printOrderId, setPrintOrderId] = useState(null)
|
||||||
|
const [payersModal, setPayersModal] = useState(null) // order object for multi-payer modal
|
||||||
|
|
||||||
|
const { data: tables = [] } = useQuery({
|
||||||
|
queryKey: ['tables'],
|
||||||
|
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
const { data: printers = [] } = useQuery({
|
||||||
|
queryKey: ['printers'],
|
||||||
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
const { data: waiters = [] } = useQuery({
|
||||||
|
queryKey: ['waiters'],
|
||||||
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`]))
|
||||||
|
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.full_name || w.username]))
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ from: filters.from, to: filters.to, page })
|
||||||
if (filters.status) params.set('status', filters.status)
|
if (filters.status) params.set('status', filters.status)
|
||||||
|
if (filters.table_id) params.set('table_id', filters.table_id)
|
||||||
|
|
||||||
const { data: orders = [], isLoading } = useQuery({
|
const { data: orders = [], isLoading } = useQuery({
|
||||||
queryKey: ['order-history', filters, page],
|
queryKey: ['order-history', filters, page],
|
||||||
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
|
queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const printMutation = useMutation({
|
||||||
|
mutationFn: ({ orderId, printerId }) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||||||
|
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||||
|
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||||
|
})
|
||||||
|
|
||||||
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
|
function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) }
|
||||||
|
|
||||||
|
const visibleOrders = filters.hideEmpty
|
||||||
|
? orders.filter(o => o.items.some(i => i.status !== 'cancelled'))
|
||||||
|
: orders
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Από</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||||
<input type="date" className="input w-40" value={filters.from} onChange={e => setF('from', e.target.value)} />
|
<DateTimeInput className={CTRL + ' w-52'} value={filters.from} onChange={e => setF('from', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Έως</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||||
<input type="date" className="input w-40" value={filters.to} onChange={e => setF('to', e.target.value)} />
|
<DateTimeInput className={CTRL + ' w-52'} value={filters.to} onChange={e => setF('to', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Κατάσταση</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Κατάσταση</label>
|
||||||
<select className="input w-44" value={filters.status} onChange={e => setF('status', e.target.value)}>
|
<select className={SELECT + ' w-44'} value={filters.status} onChange={e => setF('status', e.target.value)}>
|
||||||
<option value="">Όλες</option>
|
<option value="">Όλες</option>
|
||||||
<option value="open">Ανοιχτές</option>
|
<option value="open">Ανοιχτές</option>
|
||||||
<option value="partially_paid">Μερική πληρωμή</option>
|
<option value="partially_paid">Μερική πληρωμή</option>
|
||||||
@@ -147,48 +787,109 @@ function HistoryTab({ filters, setFilters }) {
|
|||||||
<option value="cancelled">Ακυρωμένες</option>
|
<option value="cancelled">Ακυρωμένες</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Τραπέζι</label>
|
||||||
|
<select className={SELECT + ' w-44'} value={filters.table_id} onChange={e => setF('table_id', e.target.value)}>
|
||||||
|
<option value="">Όλα</option>
|
||||||
|
{tables.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.label || `T${t.number}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 h-10 cursor-pointer select-none text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded accent-primary-700"
|
||||||
|
checked={filters.hideEmpty}
|
||||||
|
onChange={e => setF('hideEmpty', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Απόκρυψη κενών
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||||
|
{!isLoading && visibleOrders.length === 0 && (
|
||||||
{!isLoading && orders.length === 0 && (
|
|
||||||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
|
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν παραγγελίες.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orders.length > 0 && (
|
{visibleOrders.length > 0 && (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b border-gray-100">
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">#</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Τραπέζι</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Ανοίχτηκε</th>
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Ανοίχτηκε</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Έκλεισε</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Πληρώθηκε</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σημείωση</th>
|
||||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
<th className="text-right px-4 py-3 font-semibold text-gray-600">Σύνολο</th>
|
||||||
<th />
|
<th className="px-4 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{orders.map(o => {
|
{visibleOrders.map(o => {
|
||||||
const total = o.items
|
const total = o.items
|
||||||
.filter(i => i.status !== 'cancelled')
|
.filter(i => i.status !== 'cancelled')
|
||||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
|
|
||||||
|
// Opener / closer
|
||||||
|
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`
|
||||||
|
const closerName = o.closed_by ? (waiterMap[o.closed_by] || `#${o.closed_by}`) : null
|
||||||
|
|
||||||
|
// Latest payment info from items
|
||||||
|
const paidItems = o.items.filter(i => i.status === 'paid' && i.paid_by)
|
||||||
|
const payerIds = [...new Set(paidItems.map(i => i.paid_by))]
|
||||||
|
const latestPaidAt = paidItems.reduce((max, i) => (!max || i.paid_at > max) ? i.paid_at : max, null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={o.id} className="hover:bg-gray-50">
|
<tr key={o.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 text-gray-500">{o.id}</td>
|
<td className="px-4 py-3 text-gray-500">{o.id}</td>
|
||||||
<td className="px-4 py-3 font-medium text-gray-800">{o.table_id}</td>
|
<td className="px-4 py-3 font-medium text-gray-800">{tableMap[o.table_id] || `#${o.table_id}`}</td>
|
||||||
<td className="px-4 py-3 text-gray-600">
|
<td className="px-4 py-3 text-gray-600">
|
||||||
{new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })}
|
<div className="whitespace-nowrap">{fmtDt(o.opened_at)}</div>
|
||||||
|
<div className="text-xs text-gray-400">{openerName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{o.closed_at ? <>
|
||||||
|
<div className="whitespace-nowrap">{fmtDt(o.closed_at)}</div>
|
||||||
|
{closerName && <div className="text-xs text-gray-400">{closerName}</div>}
|
||||||
|
</> : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{payerIds.length === 0 ? '—' : payerIds.length === 1 ? (
|
||||||
|
<>
|
||||||
|
<div className="whitespace-nowrap text-xs text-green-700 font-medium">{waiterMap[payerIds[0]] || `#${payerIds[0]}`}</div>
|
||||||
|
{latestPaidAt && <div className="text-xs text-gray-400">{fmtDt(latestPaidAt)}</div>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setPayersModal(o)}
|
||||||
|
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 transition-colors"
|
||||||
|
>
|
||||||
|
{payerIds.length} σερβιτόροι
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3"><StatusBadge status={o.status} /></td>
|
<td className="px-4 py-3"><StatusBadge status={o.status} /></td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 text-xs max-w-[120px] truncate" title={o.notes || ''}>{o.notes || '—'}</td>
|
||||||
<td className="px-4 py-3 text-right font-semibold text-gray-800">€{total.toFixed(2)}</td>
|
<td className="px-4 py-3 text-right font-semibold text-gray-800">€{total.toFixed(2)}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/orders/${o.id}`)}
|
onClick={() => setDetailOrder(o)}
|
||||||
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
|
className="btn btn-ghost text-xs px-2 py-1 min-h-0 h-7"
|
||||||
>
|
>
|
||||||
Προβολή
|
Λεπτομέρειες
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPrintOrderId(o.id)}
|
||||||
|
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7"
|
||||||
|
>
|
||||||
|
🖨
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -198,12 +899,38 @@ function HistoryTab({ filters, setFilters }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">← Προηγ.</button>
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">← Προηγ.</button>
|
||||||
<span className="text-sm text-gray-500">Σελίδα {page}</span>
|
<span className="text-sm text-gray-500">Σελίδα {page}</span>
|
||||||
<button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. →</button>
|
<button onClick={() => setPage(p => p + 1)} disabled={orders.length < 50} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επόμ. →</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailOrder && (
|
||||||
|
<OrderDetailsModal
|
||||||
|
order={detailOrder}
|
||||||
|
tableMap={tableMap}
|
||||||
|
waiterMap={waiterMap}
|
||||||
|
printers={printers}
|
||||||
|
onClose={() => setDetailOrder(null)}
|
||||||
|
onPrint={(orderId, printerId) => printMutation.mutate({ orderId, printerId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payersModal && (
|
||||||
|
<PayersModal
|
||||||
|
order={payersModal}
|
||||||
|
waiterMap={waiterMap}
|
||||||
|
onClose={() => setPayersModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{printOrderId !== null && printers.length > 0 && (
|
||||||
|
<PrintOrderModal
|
||||||
|
printers={printers}
|
||||||
|
onClose={() => setPrintOrderId(null)}
|
||||||
|
onPrint={(printerId) => { printMutation.mutate({ orderId: printOrderId, printerId }); setPrintOrderId(null) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
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','⌫']
|
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||||
|
|
||||||
function PinInput({ value, onChange }) {
|
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() {
|
export default function WaitersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [addModal, setAddModal] = useState(false)
|
const [addModal, setAddModal] = useState(false)
|
||||||
const [pinModal, setPinModal] = useState(null) // waiter id
|
const [pinModal, setPinModal] = useState(null) // waiter id
|
||||||
|
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||||
const [newPin, setNewPin] = useState('')
|
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({
|
const { data: waiters = [], isLoading } = useQuery({
|
||||||
queryKey: ['waiters'],
|
queryKey: ['waiters'],
|
||||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: groups = [] } = useQuery({
|
||||||
|
queryKey: ['table-groups'],
|
||||||
|
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||||
|
|
||||||
const createWaiter = useMutation({
|
const createWaiter = useMutation({
|
||||||
mutationFn: (body) => client.post('/api/waiters/', body),
|
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 || 'Σφάλμα'),
|
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({
|
const toggleBlock = useMutation({
|
||||||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||||||
onSuccess: () => { invalidate() },
|
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>
|
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-3xl">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||||
@@ -84,13 +242,31 @@ export default function WaitersPage() {
|
|||||||
)}
|
)}
|
||||||
{waiters.map(w => (
|
{waiters.map(w => (
|
||||||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||||||
|
<WaiterAvatar waiter={w} size={44} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-gray-800">{w.username}</p>
|
<div className="flex items-baseline gap-2">
|
||||||
<p className="text-xs text-gray-500">{w.role}</p>
|
<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>
|
</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'}`}>
|
<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 ? 'Ενεργός' : 'Αποκλεισμένος'}
|
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||||
</span>
|
</span>
|
||||||
|
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} 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={() => 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'}`}>
|
<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 ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
|
{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="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||||
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
<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>
|
<div>
|
||||||
<label className="label">Όνομα χρήστη</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Ρόλος</label>
|
<label className="label">Ρόλος</label>
|
||||||
@@ -123,7 +307,7 @@ export default function WaitersPage() {
|
|||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
<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}
|
disabled={!newForm.username.trim() || newForm.pin.length < 4}
|
||||||
className="flex-1 btn btn-primary"
|
className="flex-1 btn btn-primary"
|
||||||
>
|
>
|
||||||
@@ -134,6 +318,76 @@ export default function WaitersPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Reset PIN modal */}
|
||||||
{pinModal !== null && (
|
{pinModal !== null && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
@@ -164,6 +418,10 @@ export default function WaitersPage() {
|
|||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{zoneModal && (
|
||||||
|
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ export default function TableDetailPage() {
|
|||||||
|
|
||||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||||
const allPaid = order && activeItems.length === 0
|
const allPaid = order && activeItems.length === 0
|
||||||
|
// Any waiter whose zone covers this table can interact with orders on it
|
||||||
const isMyOrder = order && (
|
const canInteract = !!order
|
||||||
order.opened_by === user?.id || order.waiters?.some(w => w.waiter_id === user?.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
async function openOrder() {
|
async function openOrder() {
|
||||||
try {
|
try {
|
||||||
@@ -123,12 +121,12 @@ export default function TableDetailPage() {
|
|||||||
<div className="detail-body">
|
<div className="detail-body">
|
||||||
<OrderSummary
|
<OrderSummary
|
||||||
order={order}
|
order={order}
|
||||||
selectable={isMyOrder && !paying}
|
selectable={canInteract && !paying}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleItem}
|
onToggle={toggleItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isMyOrder && activeItems.length > 0 && (
|
{canInteract && activeItems.length > 0 && (
|
||||||
<div style={{ padding: '4px 12px 8px' }}>
|
<div style={{ padding: '4px 12px 8px' }}>
|
||||||
<button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}>
|
<button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}>
|
||||||
{allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
|
{allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
|
||||||
@@ -136,7 +134,7 @@ export default function TableDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMyOrder && (
|
{canInteract && (
|
||||||
<div className="action-bar">
|
<div className="action-bar">
|
||||||
<button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}>
|
<button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}>
|
||||||
+ Προσθήκη
|
+ Προσθήκη
|
||||||
@@ -159,12 +157,6 @@ export default function TableDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMyOrder && (
|
|
||||||
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>
|
|
||||||
Ανάγνωση μόνο — άλλος σερβιτόρος
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user