feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ services:
|
||||
- LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24}
|
||||
- DATABASE_URL=sqlite:////app/data/pos.db
|
||||
- VERSION=${VERSION:-0.0.0}
|
||||
- HOST_IP=${HOST_IP:-}
|
||||
volumes:
|
||||
- ${DATA_PATH}:/app/data
|
||||
- ${LOGO_PATH}:/app/logo.png:ro
|
||||
|
||||
@@ -162,6 +162,33 @@ def close_business_day(
|
||||
return day
|
||||
|
||||
|
||||
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_business_day(
|
||||
day_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Permanently delete a business day. Only allowed when it has no shifts."""
|
||||
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Business day not found")
|
||||
if day.status == "open":
|
||||
raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first")
|
||||
|
||||
shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count()
|
||||
if shift_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.",
|
||||
)
|
||||
|
||||
db.query(Order).filter(Order.business_day_id == day_id).update(
|
||||
{"business_day_id": None}, synchronize_session=False
|
||||
)
|
||||
db.delete(day)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -120,14 +120,83 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderOut)
|
||||
@router.get("/{order_id}")
|
||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return order
|
||||
|
||||
# Resolve all user IDs referenced by this order in one query
|
||||
user_ids = set()
|
||||
user_ids.add(order.opened_by)
|
||||
if order.closed_by:
|
||||
user_ids.add(order.closed_by)
|
||||
for item in order.items:
|
||||
user_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
user_ids.add(item.paid_by)
|
||||
for log in order.audit_logs:
|
||||
if log.waiter_id:
|
||||
user_ids.add(log.waiter_id)
|
||||
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
name_map = {u.id: u.nickname or u.full_name or u.username for u in users}
|
||||
|
||||
def fmt_item(i):
|
||||
return {
|
||||
"id": i.id,
|
||||
"order_id": i.order_id,
|
||||
"product_id": i.product_id,
|
||||
"product": {"id": i.product.id, "name": i.product.name} if i.product else None,
|
||||
"added_by": i.added_by,
|
||||
"added_by_name": name_map.get(i.added_by),
|
||||
"quantity": i.quantity,
|
||||
"unit_price": i.unit_price,
|
||||
"selected_options": i.selected_options,
|
||||
"removed_ingredients": i.removed_ingredients,
|
||||
"notes": i.notes,
|
||||
"status": i.status,
|
||||
"added_at": i.added_at,
|
||||
"printed": i.printed,
|
||||
"paid_by": i.paid_by,
|
||||
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
|
||||
"paid_at": i.paid_at,
|
||||
"payment_method": i.payment_method,
|
||||
"paid_in_shift_id": i.paid_in_shift_id,
|
||||
}
|
||||
|
||||
def fmt_log(l):
|
||||
return {
|
||||
"id": l.id,
|
||||
"order_id": l.order_id,
|
||||
"event_type": l.event_type,
|
||||
"waiter_id": l.waiter_id,
|
||||
"waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None,
|
||||
"item_ids": l.item_ids,
|
||||
"amount": l.amount,
|
||||
"payment_method": l.payment_method,
|
||||
"note": l.note,
|
||||
"created_at": l.created_at,
|
||||
"offline_at": l.offline_at,
|
||||
"is_duplicate": l.is_duplicate,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"table_id": order.table_id,
|
||||
"opened_by": order.opened_by,
|
||||
"opened_at": order.opened_at,
|
||||
"status": order.status,
|
||||
"closed_at": order.closed_at,
|
||||
"closed_by": order.closed_by,
|
||||
"notes": order.notes,
|
||||
"business_day_id": order.business_day_id,
|
||||
"items": [fmt_item(i) for i in order.items],
|
||||
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
|
||||
"audit_logs": [fmt_log(l) for l in order.audit_logs],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -310,10 +379,26 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("paid", "open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Mark all still-active items as 'closed' — unpaid, closed by manager
|
||||
active_items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
closed_item_ids = []
|
||||
for item in active_items:
|
||||
item.status = "closed"
|
||||
closed_item_ids.append(item.id)
|
||||
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
|
||||
note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id,
|
||||
item_ids=closed_item_ids if closed_item_ids else None, note=note)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
return {"status": "closed"}
|
||||
@@ -419,10 +504,26 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Cancel all still-active items
|
||||
active_items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
cancelled_item_ids = []
|
||||
for item in active_items:
|
||||
item.status = "cancelled"
|
||||
cancelled_item_ids.append(item.id)
|
||||
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
|
||||
note = f"Ακύρωση από manager — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ from schemas.product import (
|
||||
CategoryReparentRequest,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _broadcast_products_changed():
|
||||
broadcast_sync("products_changed", {})
|
||||
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
@@ -118,6 +123,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -128,6 +134,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -138,6 +145,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -148,6 +156,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
|
||||
if cat:
|
||||
cat.general_sort_order = item.general_sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
@@ -176,6 +185,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
|
||||
cat.sort_order = sibling_count
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -188,6 +198,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
|
||||
setattr(cat, field, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -198,6 +209,7 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
@@ -218,6 +230,7 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
if product:
|
||||
product.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -255,6 +268,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -275,6 +289,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -305,6 +320,7 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
product.image_url = f"/static/product_images/{filename}"
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -329,3 +345,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
|
||||
else:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
@@ -178,7 +178,7 @@ def shift_orders_summary(
|
||||
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
||||
|
||||
|
||||
@router.get("/orders/history", response_model=List[OrderOut])
|
||||
@router.get("/orders/history")
|
||||
def order_history(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
@@ -191,7 +191,14 @@ def order_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
from sqlalchemy.orm import joinedload
|
||||
from models.table import Table as TableModel
|
||||
|
||||
q = db.query(Order).options(
|
||||
joinedload(Order.items).joinedload(OrderItem.product),
|
||||
joinedload(Order.waiters),
|
||||
joinedload(Order.audit_logs),
|
||||
)
|
||||
if business_day_id:
|
||||
q = q.filter(Order.business_day_id == business_day_id)
|
||||
elif from_date or to_date:
|
||||
@@ -205,7 +212,109 @@ def order_history(
|
||||
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()
|
||||
orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Collect all waiter IDs and table IDs to resolve in bulk
|
||||
waiter_ids: set[int] = set()
|
||||
table_ids: set[int] = set()
|
||||
item_waiter_ids: set[int] = set()
|
||||
for o in orders:
|
||||
if o.opened_by:
|
||||
waiter_ids.add(o.opened_by)
|
||||
if o.closed_by:
|
||||
waiter_ids.add(o.closed_by)
|
||||
if o.table_id:
|
||||
table_ids.add(o.table_id)
|
||||
for item in o.items:
|
||||
if item.added_by:
|
||||
item_waiter_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
item_waiter_ids.add(item.paid_by)
|
||||
for log in o.audit_logs:
|
||||
if log.waiter_id:
|
||||
waiter_ids.add(log.waiter_id)
|
||||
|
||||
all_waiter_ids = waiter_ids | item_waiter_ids
|
||||
waiters_map: dict[int, str] = {}
|
||||
if all_waiter_ids:
|
||||
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
|
||||
waiters_map[w.id] = w.full_name or w.username
|
||||
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all():
|
||||
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
|
||||
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
|
||||
|
||||
def _wname(wid):
|
||||
if wid is None:
|
||||
return None
|
||||
return waiters_map.get(wid, f"#{wid}")
|
||||
|
||||
def _dt_local(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
result = []
|
||||
for o in orders:
|
||||
items_out = []
|
||||
for item in o.items:
|
||||
items_out.append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
"product_id": item.product_id,
|
||||
"product": {"id": item.product.id, "name": item.product.name} if item.product else None,
|
||||
"added_by": item.added_by,
|
||||
"added_by_name": _wname(item.added_by),
|
||||
"added_at": _dt_local(item.added_at),
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"status": item.status,
|
||||
"paid_by": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
"paid_at": _dt_local(item.paid_at),
|
||||
"payment_method": item.payment_method,
|
||||
"paid_in_shift_id": item.paid_in_shift_id,
|
||||
"notes": item.notes,
|
||||
"printed": item.printed,
|
||||
"selected_options": item.selected_options,
|
||||
"removed_ingredients": item.removed_ingredients,
|
||||
})
|
||||
audit_out = []
|
||||
for log in o.audit_logs:
|
||||
audit_out.append({
|
||||
"id": log.id,
|
||||
"order_id": log.order_id,
|
||||
"event_type": log.event_type,
|
||||
"waiter_id": log.waiter_id,
|
||||
"waiter_name": _wname(log.waiter_id),
|
||||
"item_ids": log.item_ids,
|
||||
"amount": log.amount,
|
||||
"payment_method": log.payment_method,
|
||||
"note": log.note,
|
||||
"created_at": _dt_local(log.created_at),
|
||||
"offline_at": log.offline_at,
|
||||
"is_duplicate": log.is_duplicate,
|
||||
})
|
||||
result.append({
|
||||
"id": o.id,
|
||||
"table_id": o.table_id,
|
||||
"table_name": tables_map.get(o.table_id) if o.table_id else None,
|
||||
"opened_by": o.opened_by,
|
||||
"opened_by_name": _wname(o.opened_by),
|
||||
"opened_at": _dt_local(o.opened_at),
|
||||
"closed_by": o.closed_by,
|
||||
"closed_by_name": _wname(o.closed_by),
|
||||
"closed_at": _dt_local(o.closed_at),
|
||||
"status": o.status,
|
||||
"notes": o.notes,
|
||||
"business_day_id": o.business_day_id,
|
||||
"items": items_out,
|
||||
"waiters": [{"waiter_id": w.waiter_id} for w in o.waiters],
|
||||
"audit_logs": audit_out,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tables/summary")
|
||||
@@ -689,9 +798,8 @@ def business_days_list(
|
||||
orders = db.query(Order).filter(Order.business_day_id == d.id).all()
|
||||
closed_orders = [o for o in orders if o.status in ("closed", "paid")]
|
||||
cancelled_orders = [o for o in orders if o.status == "cancelled"]
|
||||
waiter_ids = set()
|
||||
for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()):
|
||||
waiter_ids.add(s.waiter_id)
|
||||
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
|
||||
waiter_ids = {s.waiter_id for s in day_shifts}
|
||||
revenue = sum(
|
||||
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||
for o in closed_orders
|
||||
@@ -710,6 +818,7 @@ def business_days_list(
|
||||
"closed_order_count": len(closed_orders),
|
||||
"cancellation_count": len(cancelled_orders),
|
||||
"waiter_count": len(waiter_ids),
|
||||
"shift_count": len(day_shifts),
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
|
||||
@@ -289,22 +289,66 @@ def get_shift(
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shift(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Permanently delete a shift, its breaks, and all orders opened during that shift."""
|
||||
from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter
|
||||
import json as _json
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.ended_at is None:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first")
|
||||
|
||||
# Find all orders opened during this shift's time window by this waiter
|
||||
orders = db.query(Order).filter(
|
||||
Order.opened_by == shift.waiter_id,
|
||||
Order.opened_at >= shift.started_at,
|
||||
Order.opened_at <= shift.ended_at,
|
||||
).all()
|
||||
|
||||
for order in orders:
|
||||
db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False)
|
||||
db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False)
|
||||
db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False)
|
||||
db.delete(order)
|
||||
|
||||
db.delete(shift)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{shift_id}/summary")
|
||||
def get_shift_summary(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||
"""Full shift summary with complete per-item waiter attribution.
|
||||
|
||||
Returns all items that either:
|
||||
- were paid in this shift (paid_in_shift_id == shift_id), OR
|
||||
- were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift.
|
||||
|
||||
Each item includes added_by_name, paid_by_name, timestamps, and order context.
|
||||
Orders include opener_name and closer_name.
|
||||
"""
|
||||
from models.order import Order
|
||||
from models.table import Table
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
|
||||
from models.table import Table
|
||||
items = db.query(OrderItem).options(
|
||||
waiter_id = shift.waiter_id
|
||||
|
||||
# Collect all relevant items: paid in this shift OR added by this waiter
|
||||
paid_items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
@@ -312,48 +356,115 @@ def get_shift_summary(
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
|
||||
# Build table_id -> display name map for all referenced tables
|
||||
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
|
||||
ordered_items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
OrderItem.added_by == waiter_id,
|
||||
OrderItem.added_at >= shift.started_at,
|
||||
OrderItem.added_at <= shift.ended_at,
|
||||
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
||||
).all()
|
||||
|
||||
# Deduplicate: union by item id, paid_items takes precedence
|
||||
items_by_id: dict[int, OrderItem] = {}
|
||||
for item in ordered_items:
|
||||
items_by_id[item.id] = item
|
||||
for item in paid_items:
|
||||
items_by_id[item.id] = item
|
||||
all_items = list(items_by_id.values())
|
||||
|
||||
# Build lookup maps
|
||||
all_waiter_ids = set()
|
||||
for item in all_items:
|
||||
all_waiter_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
all_waiter_ids.add(item.paid_by)
|
||||
all_order_ids = {item.order_id for item in all_items}
|
||||
|
||||
# Load orders with opener/closer
|
||||
orders_db: dict[int, Order] = {}
|
||||
if all_order_ids:
|
||||
for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all():
|
||||
orders_db[o.id] = o
|
||||
if o.opened_by:
|
||||
all_waiter_ids.add(o.opened_by)
|
||||
if o.closed_by:
|
||||
all_waiter_ids.add(o.closed_by)
|
||||
|
||||
# Load table names
|
||||
table_ids = {o.table_id for o in orders_db.values() if o.table_id}
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
|
||||
for t in tbl_rows:
|
||||
for t in db.query(Table).filter(Table.id.in_(table_ids)).all():
|
||||
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
|
||||
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
|
||||
|
||||
orders_seen = {}
|
||||
for item in items:
|
||||
oid = item.order_id
|
||||
if oid not in orders_seen:
|
||||
o = item.order
|
||||
tid = o.table_id if o else None
|
||||
orders_seen[oid] = {
|
||||
# Load waiter names
|
||||
waiters_map: dict[int, str] = {}
|
||||
if all_waiter_ids:
|
||||
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
|
||||
waiters_map[w.id] = w.full_name or w.username
|
||||
|
||||
def _wname(wid):
|
||||
if wid is None:
|
||||
return None
|
||||
return waiters_map.get(wid, f"#{wid}")
|
||||
|
||||
# Build orders dict
|
||||
orders_out: dict[int, dict] = {}
|
||||
for oid, o in orders_db.items():
|
||||
tid = o.table_id
|
||||
orders_out[oid] = {
|
||||
"order_id": oid,
|
||||
"table_id": tid,
|
||||
"table_name": tables_map.get(tid) if tid else None,
|
||||
"opened_at": _dt(o.opened_at) if o else None,
|
||||
"opened_at": _dt(o.opened_at),
|
||||
"closed_at": _dt(o.closed_at),
|
||||
"opener_name": _wname(o.opened_by),
|
||||
"closer_name": _wname(o.closed_by),
|
||||
"status": o.status,
|
||||
"items": [],
|
||||
}
|
||||
orders_seen[oid]["items"].append({
|
||||
|
||||
# Attach items to their orders
|
||||
for item in all_items:
|
||||
oid = item.order_id
|
||||
if oid not in orders_out:
|
||||
continue
|
||||
orders_out[oid]["items"].append({
|
||||
"id": item.id,
|
||||
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||
"status": item.status,
|
||||
"added_by_id": item.added_by,
|
||||
"added_by_name": _wname(item.added_by),
|
||||
"added_at": _dt(item.added_at),
|
||||
"paid_by_id": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
"paid_at": _dt(item.paid_at),
|
||||
"paid_in_shift_id": item.paid_in_shift_id,
|
||||
"payment_method": item.payment_method,
|
||||
})
|
||||
|
||||
# Compute hours worked
|
||||
# Remove orders with no items (shouldn't happen but safety net)
|
||||
populated_orders = [o for o in orders_out.values() if o["items"]]
|
||||
|
||||
# Compute duration
|
||||
started = shift.started_at
|
||||
ended = shift.ended_at
|
||||
duration_minutes = None
|
||||
if started and ended:
|
||||
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||
elif started:
|
||||
from datetime import datetime, timezone as tz
|
||||
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||
from datetime import timezone as tz
|
||||
now = datetime.now(tz.utc)
|
||||
s = started if started.tzinfo else started.replace(tzinfo=tz.utc)
|
||||
duration_minutes = int((now - s).total_seconds() / 60)
|
||||
|
||||
enriched = _enrich_shift(shift, db)
|
||||
enriched["orders"] = list(orders_seen.values())
|
||||
enriched["orders"] = populated_orders
|
||||
enriched["duration_minutes"] = duration_minutes
|
||||
return enriched
|
||||
|
||||
@@ -76,7 +76,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict:
|
||||
|
||||
|
||||
def _get_local_ip() -> str | None:
|
||||
"""Best-effort detection of the machine's LAN IP address."""
|
||||
"""Best-effort detection of the host machine's LAN IP address.
|
||||
When running inside Docker the socket trick returns the container/bridge IP,
|
||||
so we honour HOST_IP if it is explicitly provided via the environment."""
|
||||
import os
|
||||
if host_ip := os.environ.get("HOST_IP", "").strip():
|
||||
return host_ip
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
|
||||
@@ -4,7 +4,7 @@ import useAuthStore from './store/authStore'
|
||||
import AppLayout from './layouts/AppLayout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SetupWizard from './pages/SetupWizard'
|
||||
import OperationsPage from './pages/OperationsPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TablesPage from './pages/TablesPage'
|
||||
import OrderDetailPage from './pages/OrderDetailPage'
|
||||
import ManagementPage from './pages/ManagementPage'
|
||||
@@ -74,9 +74,9 @@ export default function App() {
|
||||
<Route path="/setup" element={<SetupWizard />} />
|
||||
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
|
||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/operations" replace />} />
|
||||
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
|
||||
<Route path="operations" element={<OperationsPage />} />
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="operations" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="tables" element={<TablesPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="management" element={<ManagementPage />} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react'
|
||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/operations', icon: BarChart2, label: 'Διοίκηση' },
|
||||
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
||||
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||
@@ -17,7 +17,7 @@ export default function Sidebar() {
|
||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||
{/* Logo / collapse toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
|
||||
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>}
|
||||
{!collapsed && <span className="font-bold text-lg tracking-wide">XeniaPOS</span>}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
|
||||
|
||||
@@ -6,6 +6,7 @@ import client from '../api/client'
|
||||
import Badge from '../ui/Badge'
|
||||
import { ConfirmModal } from '../ui/Modal'
|
||||
import { LicenseContext } from '../layouts/AppLayout'
|
||||
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
|
||||
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -479,7 +480,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
||||
|
||||
// ─── KPI Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ label, value, sub, accent = '#3758c9', pct }) {
|
||||
function KpiCard({ label, value, sub, accent = '#3758c9', pct, private: isPrivate }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
@@ -491,8 +492,19 @@ function KpiCard({ label, value, sub, accent = '#3758c9', pct }) {
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.7 }}>{label}</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1, fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: '#5a6169' }}>{sub}</div>}
|
||||
<div style={{
|
||||
fontSize: 30, fontWeight: 700, color: '#111315', letterSpacing: -0.5, lineHeight: 1.1,
|
||||
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
|
||||
filter: isPrivate ? 'blur(8px)' : 'none',
|
||||
userSelect: isPrivate ? 'none' : 'auto',
|
||||
transition: 'filter 200ms ease',
|
||||
}}>{value}</div>
|
||||
{sub && <div style={{
|
||||
fontSize: 12, color: '#5a6169',
|
||||
filter: isPrivate ? 'blur(6px)' : 'none',
|
||||
userSelect: isPrivate ? 'none' : 'auto',
|
||||
transition: 'filter 200ms ease',
|
||||
}}>{sub}</div>}
|
||||
{pct != null && (
|
||||
<div style={{ marginTop: 4, height: 6, borderRadius: 3, background: '#edeff1', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.min(100, pct)}%`, height: '100%', background: accent, borderRadius: 3, transition: 'width 300ms ease' }} />
|
||||
@@ -535,6 +547,7 @@ function TableChip({ name, status, amount, onClick }) {
|
||||
// ─── End shift confirmation modal ─────────────────────────────────────────────
|
||||
|
||||
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||||
const [notes, setNotes] = useState('')
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
@@ -559,10 +572,24 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||||
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||||
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
|
||||
rows={2}
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
|
||||
onFocus={e => e.target.style.borderColor = '#6366f1'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
<button onClick={() => onConfirm(notes)} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
|
||||
</button>
|
||||
@@ -572,123 +599,6 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shift summary modal ───────────────────────────────────────────────────────
|
||||
|
||||
function ShiftSummaryModal({ shiftId, onConfirm }) {
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
client.get(`/api/shifts/${shiftId}/summary`)
|
||||
.then(r => setSummary(r.data))
|
||||
.catch(() => setSummary(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [shiftId])
|
||||
|
||||
function fmtMins(mins) {
|
||||
if (mins == null) return '—'
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
|
||||
}
|
||||
|
||||
const totalItems = summary?.orders?.reduce((s, o) => s + o.items.reduce((ss, i) => ss + i.quantity, 0), 0) ?? 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 860,
|
||||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#111315' }}>Σύνοψη Βάρδιας</div>
|
||||
{summary && <div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>{summary.waiter_name}</div>}
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση…</div>}
|
||||
|
||||
{!loading && summary && (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{/* KPI row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 20 }}>
|
||||
{[
|
||||
{ label: 'Έναρξη', value: fmtTime(summary.started_at) },
|
||||
{ label: 'Ώρες', value: fmtMins(summary.duration_minutes) },
|
||||
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEuro(summary.starting_cash) : '—' },
|
||||
{ label: 'Είσπραξη', value: fmtEuro(summary.total_collected), accent: '#2f9e5e' },
|
||||
].map(k => (
|
||||
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 12, padding: '16px 12px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.6, lineHeight: 1.3 }}>{k.label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: k.accent || '#111315', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>{k.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Net to deliver */}
|
||||
<div style={{ background: '#eff6ff', borderRadius: 12, padding: '12px 16px', marginBottom: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#3758c9', textTransform: 'uppercase', letterSpacing: 0.5 }}>Σύνολο προς παράδοση</div>
|
||||
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 2 }}>Είσπραξη + αρχικά μετρητά</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#3758c9', fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace" }}>
|
||||
{fmtEuro(summary.net_to_deliver)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders breakdown */}
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 10 }}>
|
||||
Παραγγελίες ({summary.orders.length}) · {totalItems} αντικείμενα
|
||||
</div>
|
||||
{summary.orders.length === 0 && (
|
||||
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '16px 0' }}>Δεν υπάρχουν πληρωμές σε αυτή τη βάρδια</p>
|
||||
)}
|
||||
{summary.orders.map(o => (
|
||||
<div key={o.order_id} style={{ borderRadius: 10, border: '1px solid #edeff1', marginBottom: 8, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 14px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
|
||||
Παραγγελία #{o.order_id}
|
||||
{(o.table_name || o.table_id) && <span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>· Τραπέζι {o.table_name ?? o.table_id}</span>}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#2f9e5e' }}>
|
||||
{fmtEuro(o.items.reduce((s, i) => s + i.subtotal, 0))}
|
||||
</span>
|
||||
</div>
|
||||
{o.items.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 14px', fontSize: 13, borderTop: '1px solid #f4f4f2' }}>
|
||||
<span style={{ color: '#374151' }}>{item.product_name} <span style={{ color: '#8a9099' }}>×{item.quantity}</span></span>
|
||||
<span style={{ color: '#111315', fontWeight: 500 }}>{fmtEuro(item.subtotal)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !summary && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Σφάλμα φόρτωσης</div>
|
||||
)}
|
||||
|
||||
{/* Footer — no close on outside click, must confirm */}
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
width: '100%', height: 44, borderRadius: 12,
|
||||
background: '#3758c9', color: 'white', border: 'none',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>✓ Επιβεβαίωση και Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Compose for single waiter (quick message from shift row) ─────────────────
|
||||
|
||||
function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
|
||||
@@ -742,7 +652,7 @@ function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter }) {
|
||||
function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter, privacyMode }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white', border: '1px solid #edeff1',
|
||||
@@ -797,7 +707,12 @@ function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, o
|
||||
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 1 }}>
|
||||
από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)}
|
||||
{s.total_collected > 0 && (
|
||||
<span style={{ color: '#2f9e5e', fontWeight: 600, marginLeft: 6 }}>{fmtEuro(s.total_collected)}</span>
|
||||
<span style={{
|
||||
color: '#2f9e5e', fontWeight: 600, marginLeft: 6,
|
||||
filter: privacyMode ? 'blur(6px)' : 'none',
|
||||
userSelect: privacyMode ? 'none' : 'auto',
|
||||
transition: 'filter 200ms ease',
|
||||
}}>{fmtEuro(s.total_collected)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1216,7 +1131,7 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
|
||||
|
||||
// ─── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OperationsPage() {
|
||||
export default function DashboardPage() {
|
||||
const [showStartShift, setShowStartShift] = useState(false)
|
||||
const [closeDetails, setCloseDetails] = useState(null)
|
||||
const [forceClosing, setForceClosing] = useState(false)
|
||||
@@ -1229,6 +1144,7 @@ export default function OperationsPage() {
|
||||
const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id
|
||||
// Quick message to single waiter
|
||||
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
|
||||
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const license = useContext(LicenseContext)
|
||||
@@ -1315,15 +1231,15 @@ export default function OperationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndShiftConfirm() {
|
||||
async function handleEndShiftConfirm(notes) {
|
||||
if (!endShiftTarget) return
|
||||
setEndShiftBusy(true)
|
||||
try {
|
||||
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {})
|
||||
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
|
||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||
const summaryId = endShiftTarget.id
|
||||
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
|
||||
setEndShiftTarget(null)
|
||||
setShiftSummaryId(summaryId)
|
||||
setShiftSummaryId(summaryTarget)
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Σφάλμα')
|
||||
} finally {
|
||||
@@ -1432,7 +1348,32 @@ export default function OperationsPage() {
|
||||
{isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setPrivacyMode(p => { const next = !p; localStorage.setItem('privacyMode', next); return next })}
|
||||
title={privacyMode ? 'Εμφάνιση ποσών' : 'Απόκρυψη ποσών'}
|
||||
style={{
|
||||
height: 38, width: 38, borderRadius: 10, flexShrink: 0,
|
||||
border: '1px solid #dfe2e6',
|
||||
background: privacyMode ? '#1e293b' : 'white',
|
||||
color: privacyMode ? '#94a3b8' : '#5a6169',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, transition: 'background 200ms, color 200ms',
|
||||
}}
|
||||
>
|
||||
{privacyMode ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<button onClick={() => handleCloseDay(false)} style={{
|
||||
height: 38, padding: '0 18px', borderRadius: 10,
|
||||
@@ -1456,6 +1397,7 @@ export default function OperationsPage() {
|
||||
value={fmtEuro(totalRevenue)}
|
||||
sub={`${dayOrders.length} παραγγελί${dayOrders.length !== 1 ? 'ες' : 'α'} · μ.ο. ${fmtEuro(avgTicket)}`}
|
||||
accent="#3758c9"
|
||||
private={privacyMode}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Τραπέζια σε χρήση"
|
||||
@@ -1475,6 +1417,7 @@ export default function OperationsPage() {
|
||||
value={fmtEuro(totalCollected)}
|
||||
sub={`Σύνολο από ${activeShifts.length} βάρδι${activeShifts.length === 1 ? 'α' : 'ες'}`}
|
||||
accent="#0d7a8a"
|
||||
private={privacyMode}
|
||||
/>
|
||||
{pendingPrintOrders.length > 0 && (
|
||||
<KpiCard
|
||||
@@ -1500,6 +1443,7 @@ export default function OperationsPage() {
|
||||
onStartShift={() => setShowStartShift(true)}
|
||||
onEndShift={(shift) => setEndShiftTarget(shift)}
|
||||
onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })}
|
||||
privacyMode={privacyMode}
|
||||
/>
|
||||
|
||||
{/* Tables overview — SECOND */}
|
||||
@@ -1595,9 +1539,10 @@ export default function OperationsPage() {
|
||||
)}
|
||||
|
||||
{shiftSummaryId && (
|
||||
<ShiftSummaryModal
|
||||
shiftId={shiftSummaryId}
|
||||
onConfirm={() => setShiftSummaryId(null)}
|
||||
<ShiftDetailModal
|
||||
shiftId={shiftSummaryId.id}
|
||||
shiftWaiterId={shiftSummaryId.waiter_id}
|
||||
onClose={() => setShiftSummaryId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,788 +0,0 @@
|
||||
import { useState, useContext } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import Button from '../ui/Button'
|
||||
import { LicenseContext } from '../layouts/AppLayout'
|
||||
|
||||
// ─── Business Day + Shift Management Panel ───────────────────────────────────
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtShiftDuration(iso) {
|
||||
if (!iso) return ''
|
||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}λ`
|
||||
const h = Math.floor(mins / 60); const m = mins % 60
|
||||
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||||
}
|
||||
|
||||
function StartShiftModal({ waiters, onClose, onStart }) {
|
||||
const [waiterId, setWaiterId] = useState('')
|
||||
const [cash, setCash] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<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">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||||
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
|
||||
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά (€)</label>
|
||||
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => setCash(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||||
<button onClick={submit} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
|
||||
const hasPendingPayments = details.partially_paid > 0
|
||||
|
||||
if (!hasPendingPayments) {
|
||||
// All tables open but nothing owed — safe to close, just needs confirmation
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
|
||||
</p>
|
||||
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Some tables have unpaid items — revenue will be lost, needs hard warning
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-red-600 text-lg font-bold">!</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
|
||||
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
|
||||
</p>
|
||||
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
|
||||
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BusinessDayPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [showStartShift, setShowStartShift] = useState(false)
|
||||
const [closeDetails, setCloseDetails] = useState(null)
|
||||
const [forceClosing, setForceClosing] = useState(false)
|
||||
const [licenseBlock, setLicenseBlock] = useState(null)
|
||||
const license = useContext(LicenseContext)
|
||||
|
||||
const { data: businessDay } = useQuery({
|
||||
queryKey: ['business-day'],
|
||||
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const { data: activeShifts = [] } = useQuery({
|
||||
queryKey: ['active-shifts'],
|
||||
queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const { data: allWaiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const waitersWithoutShift = allWaiters.filter(
|
||||
w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
|
||||
)
|
||||
|
||||
const openDayMut = useMutation({
|
||||
mutationFn: () => client.post('/api/business-day/open', {}),
|
||||
onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
|
||||
onError: (e) => {
|
||||
const detail = e.response?.data?.detail
|
||||
if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') {
|
||||
setLicenseBlock({ code: detail.code, message: detail.message })
|
||||
} else {
|
||||
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handleOpenDay() {
|
||||
if (license?.isBlocked) {
|
||||
setLicenseBlock({
|
||||
code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED',
|
||||
message: license.lock_reason === 'admin'
|
||||
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
|
||||
: 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.',
|
||||
})
|
||||
return
|
||||
}
|
||||
openDayMut.mutate()
|
||||
}
|
||||
|
||||
async function handleCloseDay(force = false) {
|
||||
setForceClosing(force)
|
||||
try {
|
||||
await client.post('/api/business-day/close', { force })
|
||||
toast.success('Ημέρα έκλεισε!')
|
||||
setCloseDetails(null)
|
||||
qc.invalidateQueries({ queryKey: ['business-day'] })
|
||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||||
} catch (e) {
|
||||
const detail = e.response?.data?.detail
|
||||
if (e.response?.status === 409 && detail?.open_orders) {
|
||||
setCloseDetails(detail)
|
||||
} else {
|
||||
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
|
||||
}
|
||||
} finally {
|
||||
setForceClosing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndShift(shiftId, waiterName) {
|
||||
if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return
|
||||
try {
|
||||
await client.post(`/api/shifts/manager/end/${shiftId}`, {})
|
||||
toast.success('Βάρδια έκλεισε')
|
||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Σφάλμα')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartShift(waiterId, startingCash) {
|
||||
await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
|
||||
toast.success('Βάρδια ξεκίνησε!')
|
||||
qc.invalidateQueries({ queryKey: ['active-shifts'] })
|
||||
}
|
||||
|
||||
const isOpen = !!businessDay
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border overflow-hidden"
|
||||
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-5 py-3"
|
||||
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: isOpen ? '#16a34a' : '#9ca3af',
|
||||
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
|
||||
}} />
|
||||
<div>
|
||||
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
|
||||
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
|
||||
</span>
|
||||
{isOpen && businessDay?.opened_at && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
από {fmtTime(businessDay.opened_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isOpen && waitersWithoutShift.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowStartShift(true)}
|
||||
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
+ Βάρδια
|
||||
</button>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<button
|
||||
onClick={() => handleCloseDay(false)}
|
||||
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
|
||||
>
|
||||
Κλείσιμο Ημέρας
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleOpenDay}
|
||||
disabled={openDayMut.isPending}
|
||||
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
|
||||
>
|
||||
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active shifts */}
|
||||
{isOpen && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 bg-white">
|
||||
{activeShifts.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeShifts.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
|
||||
{s.total_collected > 0 && (
|
||||
<span className="text-xs text-green-700 ml-2 font-medium">€{s.total_collected.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEndShift(s.id, s.waiter_name)}
|
||||
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
|
||||
title="Τέλος βάρδιας"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStartShift && (
|
||||
<StartShiftModal
|
||||
waiters={waitersWithoutShift}
|
||||
onClose={() => setShowStartShift(false)}
|
||||
onStart={handleStartShift}
|
||||
/>
|
||||
)}
|
||||
{closeDetails && (
|
||||
<CloseConfirmModal
|
||||
details={closeDetails}
|
||||
onClose={() => setCloseDetails(null)}
|
||||
onConfirm={() => handleCloseDay(true)}
|
||||
busy={forceClosing}
|
||||
/>
|
||||
)}
|
||||
{licenseBlock && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
|
||||
}`}>
|
||||
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-[15px] font-bold text-slate-900">
|
||||
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
|
||||
</h2>
|
||||
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
|
||||
<button
|
||||
onClick={() => setLicenseBlock(null)}
|
||||
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
|
||||
>
|
||||
Κλείσιμο
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const FILTERS = ['all', 'open', 'partially_paid', 'free']
|
||||
const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const COLORS = {
|
||||
open: {
|
||||
label: 'Ανοιχτό',
|
||||
tint: '#eef7f0', tintStrong: '#d7ecdc',
|
||||
accent: '#2f9e5e', ink: '#1f7042',
|
||||
},
|
||||
partially_paid: {
|
||||
label: 'Μερική πληρ.',
|
||||
tint: '#f4eefb', tintStrong: '#e3d4f3',
|
||||
accent: '#7a44c9', ink: '#57309a',
|
||||
},
|
||||
free: {
|
||||
label: 'Ελεύθερο',
|
||||
tint: '#f4f4f2', tintStrong: '#dfe2e6',
|
||||
accent: '#8a9099', ink: '#5a6169',
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatEuro(n) {
|
||||
return '€' + parseFloat(n).toFixed(2)
|
||||
}
|
||||
|
||||
function formatDuration(openedAt) {
|
||||
const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
function occupiedMinsFromDate(openedAt) {
|
||||
return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
|
||||
}
|
||||
|
||||
function orderTotal(items = []) {
|
||||
return items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
}
|
||||
|
||||
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 = [], hasPendingPrint = false, 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 */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{hasPendingPrint && (
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: '#92400e', color: '#fcd34d',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
⏳ Εκκρεμής εκτύπωση
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [retryingId, setRetryingId] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: tables = [], isLoading: tablesLoading } = useQuery({
|
||||
queryKey: ['tables'],
|
||||
queryFn: () => client.get('/api/tables/').then(r => r.data),
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: orders = [], isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['orders-active'],
|
||||
queryFn: () => client.get('/api/orders/').then(r => r.data),
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const { data: waiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// 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 ?? null
|
||||
return [w.id, { name, shortName, avatarUrl }]
|
||||
}))
|
||||
|
||||
const tableCards = tables.map(table => {
|
||||
const order = orders.find(o =>
|
||||
o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
|
||||
)
|
||||
const tableStatus = order ? order.status : 'free'
|
||||
const hasPendingPrint = order
|
||||
? order.items.some(i => i.status === 'active' && !i.printed)
|
||||
: false
|
||||
return { table, order, tableStatus, hasPendingPrint }
|
||||
})
|
||||
|
||||
const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
|
||||
|
||||
async function retrySingleOrder(orderId) {
|
||||
setRetryingId(orderId)
|
||||
try {
|
||||
const res = await client.post(`/api/orders/${orderId}/retry-print`)
|
||||
const results = res.data.print_results ?? []
|
||||
const allOk = results.length === 0 || results.every(r => r.success)
|
||||
if (allOk) {
|
||||
toast.success('Εκτυπώθηκε επιτυχώς')
|
||||
} else {
|
||||
const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
|
||||
toast.error(`Αποτυχία: ${failed}`)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['orders-active'] })
|
||||
} catch {
|
||||
toast.error('Σφάλμα επικοινωνίας')
|
||||
} finally {
|
||||
setRetryingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryAllOrders() {
|
||||
for (const { order } of pendingPrintOrders) {
|
||||
if (order) await retrySingleOrder(order.id)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? tableCards
|
||||
: tableCards.filter(c => c.tableStatus === filter)
|
||||
|
||||
if (tablesLoading || ordersLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-6 space-y-6">
|
||||
<BusinessDayPanel />
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant={filter === f ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{filtered.map(({ table, order, tableStatus, hasPendingPrint }) => {
|
||||
const waiterNames = order
|
||||
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
|
||||
: []
|
||||
const amount = order ? orderTotal(order.items) : null
|
||||
|
||||
return (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
hasPendingPrint={hasPendingPrint}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
|
||||
{pendingPrintOrders.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span style={{ fontSize: 20 }}>⏳</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
|
||||
<p className="text-xs text-orange-700 mt-0.5">
|
||||
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
|
||||
onClick={retryAllOrders}
|
||||
disabled={retryingId !== null}
|
||||
>
|
||||
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-orange-50">
|
||||
{pendingPrintOrders.map(({ table, order }) => {
|
||||
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
|
||||
const tableName = table.label || `T${table.number}`
|
||||
return (
|
||||
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
|
||||
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
|
||||
{tableName}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
|
||||
onClick={() => retrySingleOrder(order.id)}
|
||||
disabled={retryingId === order.id}
|
||||
>
|
||||
{retryingId === order.id ? '…' : 'Εκτύπωση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import Badge from '../ui/Badge'
|
||||
import { ConfirmModal } from '../ui/Modal'
|
||||
import PaymentMethodModal from '../ui/PaymentMethodModal'
|
||||
|
||||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||
@@ -40,6 +41,24 @@ function itemTotal(item) {
|
||||
return (item.unit_price * item.quantity).toFixed(2)
|
||||
}
|
||||
|
||||
const PAYMENT_METHOD_STYLES = {
|
||||
cash: { label: 'ΜΕΤΡΗΤΑ', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
|
||||
card: { label: 'ΚΑΡΤΑ', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
|
||||
transfer: { label: 'ΤΡΑΠΕΖΙΚΗ', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
|
||||
treat: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
|
||||
comp: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
|
||||
}
|
||||
|
||||
function PayMethodChip({ method }) {
|
||||
if (!method) return null
|
||||
const s = PAYMENT_METHOD_STYLES[method] || { label: method.toUpperCase(), color: '#374151', bg: '#f3f4f6', border: '#d1d5db' }
|
||||
return (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4, background: s.bg, color: s.color, border: `1px solid ${s.border}`, letterSpacing: 0.3 }}>
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '—'
|
||||
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
@@ -59,6 +78,9 @@ 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>
|
||||
}
|
||||
|
||||
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => {
|
||||
@@ -70,8 +92,20 @@ function AuditTab({ order, waiterMap }) {
|
||||
: 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'
|
||||
// Show offline_at (real payment time) when available, else server created_at
|
||||
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
|
||||
|
||||
// Resolve item names for ITEMS_ADDED and ITEM_CANCELLED events
|
||||
let itemNames = []
|
||||
if (log.item_ids && (log.event_type === 'ITEMS_ADDED' || log.event_type === 'ITEM_CANCELLED')) {
|
||||
try {
|
||||
const ids = JSON.parse(log.item_ids)
|
||||
itemNames = ids.map(id => {
|
||||
const item = itemsById[id]
|
||||
return item ? `${item.product?.name ?? `#${item.product_id}`} ×${item.quantity}` : null
|
||||
}).filter(Boolean)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
@@ -90,7 +124,14 @@ function AuditTab({ order, waiterMap }) {
|
||||
</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
<span className="ml-1"><PayMethodChip method={log.payment_method} /></span>
|
||||
)}
|
||||
{itemNames.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{itemNames.map((name, i) => (
|
||||
<span key={i} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
@@ -115,6 +156,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ['order', orderId],
|
||||
@@ -179,11 +221,21 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
})
|
||||
|
||||
const payItems = useMutation({
|
||||
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
|
||||
mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
|
||||
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα πληρωμής'),
|
||||
})
|
||||
|
||||
function requestPay(item_ids) {
|
||||
setPayPending(item_ids)
|
||||
}
|
||||
|
||||
function confirmPay(method) {
|
||||
if (!payPending) return
|
||||
payItems.mutate({ item_ids: payPending, payment_method: method })
|
||||
setPayPending(null)
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!confirmAction) return
|
||||
const { type, payload } = confirmAction
|
||||
@@ -279,26 +331,37 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
{order.items.map(item => {
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
const isClosedItem = item.status === 'closed'
|
||||
const badgeStatus = isClosedItem ? 'closed_item' : item.status
|
||||
return (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
{item.paid_by && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
|
||||
{item.payment_method && (
|
||||
<span style={{ color: (PAYMENT_METHOD_STYLES[item.payment_method] || {}).color ?? '#374151', fontWeight: 600 }}>
|
||||
{' · '}{(PAYMENT_METHOD_STYLES[item.payment_method] || { label: item.payment_method.toUpperCase() }).label}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isClosedItem && (
|
||||
<p className="text-xs text-amber-700 mt-0.5 font-medium">Απλήρωτο — κλείστηκε από manager</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
onClick={() => requestPay([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
@@ -313,14 +376,15 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{isOpen && !readOnly && activeItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||
onClick={() => requestPay(activeItems.map(i => i.id))}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Πληρωμή όλων
|
||||
@@ -357,6 +421,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payPending && (
|
||||
<PaymentMethodModal
|
||||
onSelect={confirmPay}
|
||||
onCancel={() => setPayPending(null)}
|
||||
title={payPending.length === 1 ? 'Τρόπος Πληρωμής;' : `Πληρωμή ${payPending.length} αντικειμένων;`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmAction && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import AppInfoTab from './tabs/AppInfoTab'
|
||||
import ColoursTab from './tabs/ColoursTab'
|
||||
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||
import OperationTab from './tabs/OperationTab'
|
||||
import PrintFontsTab from './tabs/PrintFontsTab'
|
||||
import SecurityTab from './tabs/SecurityTab'
|
||||
@@ -13,7 +12,6 @@ const TABS = [
|
||||
{ id: 'operation', label: 'Λειτουργία' },
|
||||
{ id: 'colours', label: 'Εμφάνιση' },
|
||||
{ id: 'print-fonts', label: 'Εκτύπωση' },
|
||||
{ id: 'development', label: 'dev' },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -28,7 +26,6 @@ export default function SettingsPage() {
|
||||
{activeTab === 'operation' && <OperationTab />}
|
||||
{activeTab === 'colours' && <ColoursTab />}
|
||||
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||
{activeTab === 'development' && <DevelopmentTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function ReportsPage() {
|
||||
restaurant: 'today',
|
||||
ops: 'printer-history',
|
||||
})
|
||||
const [subProps, setSubProps] = useState({})
|
||||
|
||||
const parent = PARENT_TABS.find(t => t.id === activeParent)
|
||||
const activeSubId = activeSubByParent[activeParent]
|
||||
@@ -76,15 +77,26 @@ export default function ReportsPage() {
|
||||
const { Component: SubComponent } = sub
|
||||
|
||||
function setActiveSub(id) {
|
||||
setSubProps({})
|
||||
setActiveSubByParent(prev => ({ ...prev, [activeParent]: id }))
|
||||
}
|
||||
|
||||
function navigateTo({ parent: parentId, sub: subId, ...props }) {
|
||||
setSubProps(props)
|
||||
setActiveSubByParent(prev => ({ ...prev, [parentId]: subId }))
|
||||
setActiveParent(parentId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabGroup tabs={PARENT_TABS} active={activeParent} onChange={setActiveParent} />
|
||||
<TabBar tabs={parent.subTabs} active={activeSubId} onChange={setActiveSub} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<SubComponent />
|
||||
<SubComponent
|
||||
key={`${activeParent}-${activeSubId}-${JSON.stringify(subProps)}`}
|
||||
onNavigate={navigateTo}
|
||||
{...subProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import DrillDownModal from '../shared/DrillDownModal'
|
||||
import OrderDetailModal from '../shared/OrderDetailModal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
@@ -20,11 +20,11 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'cancelled', label: 'Ακυρωμένη' },
|
||||
]
|
||||
|
||||
export default function OrderHistory() {
|
||||
const [mode, setMode] = useState('range')
|
||||
export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
const [mode, setMode] = useState(initialBusinessDayId ? 'workday' : 'range')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [businessDayId, setBusinessDayId] = useState('all')
|
||||
const [businessDayId, setBusinessDayId] = useState(initialBusinessDayId ? String(initialBusinessDayId) : 'all')
|
||||
const [statusF, setStatusF] = useState('all')
|
||||
const [waiterF, setWaiterF] = useState('all')
|
||||
const [tableF, setTableF] = useState('all')
|
||||
@@ -95,12 +95,12 @@ export default function OrderHistory() {
|
||||
</THead>
|
||||
<tbody>
|
||||
{orders.slice(0, 200).map(o => {
|
||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const isCancelled = o.status === 'cancelled'
|
||||
return (
|
||||
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_id}</TD>
|
||||
<TD>{o.table_name ?? o.table_id}</TD>
|
||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
||||
<TD><StatusBadge status={o.status} /></TD>
|
||||
@@ -130,45 +130,7 @@ export default function OrderHistory() {
|
||||
</div>
|
||||
|
||||
{drillOrder && (
|
||||
<DrillDownModal
|
||||
title={`Order #${drillOrder.id}`}
|
||||
subtitle={`${fmtDateTime(drillOrder.opened_at)} · Τραπέζι ${drillOrder.table_id}`}
|
||||
onClose={() => setDrillOrder(null)}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<StatusBadge status={drillOrder.status} />
|
||||
{drillOrder.notes && (
|
||||
<span className="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-800 ring-1 ring-inset ring-amber-200">
|
||||
Σημείωση: {drillOrder.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Προϊόν</TH><TH align="right">Ποσ.</TH><TH align="right">Τιμή</TH><TH align="right">Υποσύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{(drillOrder.items || []).map((item, i) => (
|
||||
<TR key={i} striped>
|
||||
<TD className="font-medium">{item.product?.name ?? `#${item.product_id}`}</TD>
|
||||
<TD mono align="right">×{item.quantity}</TD>
|
||||
<TD mono align="right">{fmtEUR(item.unit_price)}</TD>
|
||||
<TD mono align="right" className="font-semibold">{fmtEUR(item.unit_price * item.quantity)}</TD>
|
||||
<TD><StatusBadge status={item.status} /></TD>
|
||||
</TR>
|
||||
))}
|
||||
<tr className="bg-slate-50">
|
||||
<TD colSpan={3} className="py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολο</TD>
|
||||
<TD mono align="right" className="py-3 text-base font-semibold text-slate-900">
|
||||
{fmtEUR((drillOrder.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0))}
|
||||
</TD>
|
||||
<TD />
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</DrillDownModal>
|
||||
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import DrillDownModal from '../shared/DrillDownModal'
|
||||
import OrderDetailModal from '../shared/OrderDetailModal'
|
||||
import ShiftDetailModal from '../shared/ShiftDetailModal'
|
||||
import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration } from '../shared/reportDesignTokens'
|
||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } from '../shared/reportDesignTokens'
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10) }
|
||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||
|
||||
// ── Workday drill-down modal ────────────────────────────────────────────────
|
||||
function WorkDayModal({ day, onClose, onDeleteShift }) {
|
||||
const [tab, setTab] = useState('orders')
|
||||
const [drillOrder, setDrillOrder] = useState(null)
|
||||
const [detailShift, setDetailShift] = useState(null)
|
||||
|
||||
const { data: ordersData } = useQuery({
|
||||
queryKey: ['business-day-orders', day.id],
|
||||
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: day.id, page_size: 200 } }).then(r => r.data),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const { data: shiftsData } = useQuery({
|
||||
queryKey: ['shifts-for-day', day.id],
|
||||
queryFn: () => client.get('/api/reports/shifts', { params: { business_day_id: day.id } }).then(r => r.data),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const orders = Array.isArray(ordersData) ? ordersData : []
|
||||
const shifts = shiftsData?.shifts || []
|
||||
|
||||
const TABS = [
|
||||
{ key: 'orders', label: `Παραγγελίες (${orders.length})` },
|
||||
{ key: 'shifts', label: `Βάρδιες (${shifts.length})` },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrillDownModal
|
||||
title={`Εργάσιμη Μέρα · ${fmtDate(day.opened_at)}`}
|
||||
subtitle={`${fmtEUR(day.revenue)} έσοδα · ${fmtTime(day.opened_at)} – ${day.closed_at ? fmtTime(day.closed_at) : 'ανοιχτή'}`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 px-6">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${tab === t.key ? 'border-slate-800 text-slate-900' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Orders tab */}
|
||||
{tab === 'orders' && (
|
||||
orders.length === 0
|
||||
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν παραγγελίες</div>
|
||||
: <DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{orders.map(o => {
|
||||
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
return (
|
||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_name ?? o.table_id}</TD>
|
||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
|
||||
<TD mono align="right">{(o.items || []).length}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||
<TD><StatusBadge status={o.status} /></TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
{/* Shifts tab */}
|
||||
{tab === 'shifts' && (
|
||||
shifts.length === 0
|
||||
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν βάρδιες</div>
|
||||
: <DataTable>
|
||||
<THead>
|
||||
<TH>Σερβιτόρος</TH>
|
||||
<TH>Έναρξη</TH>
|
||||
<TH>Λήξη</TH>
|
||||
<TH align="right">Διάρκεια</TH>
|
||||
<TH align="right">Εισπράχθηκαν</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH className="w-20" />
|
||||
</THead>
|
||||
<tbody>
|
||||
{shifts.map(s => (
|
||||
<TR key={s.id} striped onClick={() => setDetailShift(s)} className="cursor-pointer">
|
||||
<TD><WaiterAvatar name={s.waiter_name} id={s.waiter_id} /></TD>
|
||||
<TD mono>{fmtDateTime(s.started_at)}</TD>
|
||||
<TD mono>{s.ended_at ? fmtDateTime(s.ended_at) : <span className="text-sky-600 font-medium">— ενεργή —</span>}</TD>
|
||||
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
|
||||
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
|
||||
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||
<TD align="right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDetailShift(s) }}
|
||||
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</button>
|
||||
{!s.is_active && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDeleteShift(s) }}
|
||||
className="rounded border border-red-200 bg-white p-0.5 text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Διαγραφή βάρδιας"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
</DrillDownModal>
|
||||
|
||||
{drillOrder && (
|
||||
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
|
||||
)}
|
||||
|
||||
{detailShift && (
|
||||
<ShiftDetailModal
|
||||
shiftId={detailShift.id}
|
||||
shiftWaiterId={detailShift.waiter_id}
|
||||
onClose={() => setDetailShift(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ───────────────────────────────────────────────────────────────
|
||||
export default function WorkDaySummary() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [drillId, setDrillId] = useState(null)
|
||||
const [drillDay, setDrillDay] = useState(null)
|
||||
const [deleteDay, setDeleteDay] = useState(null)
|
||||
const [deleteShift, setDeleteShift] = useState(null)
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['business-days', from, to],
|
||||
queryFn: () => client.get('/api/reports/business-days', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const { data: drillData } = useQuery({
|
||||
queryKey: ['business-day-orders', drillId],
|
||||
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: drillId, page_size: 200 } }).then(r => r.data),
|
||||
enabled: !!drillId,
|
||||
staleTime: 60 * 1000,
|
||||
const deleteDayMutation = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/business-day/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Η εργάσιμη μέρα διαγράφηκε')
|
||||
qc.invalidateQueries({ queryKey: ['business-days'] })
|
||||
qc.invalidateQueries({ queryKey: ['business-days-list'] })
|
||||
setDeleteDay(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής εργάσιμης μέρας')
|
||||
setDeleteDay(null)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteShiftMutation = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/shifts/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Η βάρδια και οι παραγγελίες της διαγράφηκαν')
|
||||
qc.invalidateQueries({ queryKey: ['shifts'] })
|
||||
qc.invalidateQueries({ queryKey: ['shifts-for-day'] })
|
||||
qc.invalidateQueries({ queryKey: ['business-days'] })
|
||||
qc.invalidateQueries({ queryKey: ['business-days-list'] })
|
||||
setDeleteShift(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής βάρδιας')
|
||||
setDeleteShift(null)
|
||||
},
|
||||
})
|
||||
|
||||
const days = data?.business_days || []
|
||||
const drillDay = drillId ? days.find(d => d.id === drillId) : null
|
||||
const drillOrders = Array.isArray(drillData) ? drillData : []
|
||||
|
||||
const chartData = [...days].reverse().map(d => ({
|
||||
date: fmtDate(d.opened_at),
|
||||
@@ -90,10 +259,11 @@ export default function WorkDaySummary() {
|
||||
<TH align="right">Ακυρώσεις</TH>
|
||||
<TH align="right">Σερβιτόροι</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH className="w-10" />
|
||||
</THead>
|
||||
<tbody>
|
||||
{days.map(d => (
|
||||
<TR key={d.id} onClick={() => setDrillId(d.id)} striped>
|
||||
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
|
||||
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
|
||||
<TD mono>{fmtTime(d.opened_at)}</TD>
|
||||
<TD mono>{d.closed_at ? fmtTime(d.closed_at) : '—'}</TD>
|
||||
@@ -103,6 +273,18 @@ export default function WorkDaySummary() {
|
||||
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
|
||||
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
|
||||
<TD><StatusBadge status={d.status} pulse /></TD>
|
||||
<TD align="right">
|
||||
{d.status === 'closed' && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteDay(d) }}
|
||||
className={`rounded border p-0.5 bg-white ${(d.shift_count ?? 0) === 0 ? 'border-red-200 text-red-400 hover:bg-red-50 hover:text-red-600' : 'border-slate-200 text-slate-300 cursor-not-allowed'}`}
|
||||
title={(d.shift_count ?? 0) === 0 ? 'Διαγραφή εργάσιμης μέρας' : `Έχει ${d.shift_count} βάρδιες — διαγράψτε τες πρώτα`}
|
||||
disabled={(d.shift_count ?? 0) > 0}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -113,32 +295,29 @@ export default function WorkDaySummary() {
|
||||
</div>
|
||||
|
||||
{drillDay && (
|
||||
<DrillDownModal
|
||||
title={`Εργάσιμη Μέρα · ${fmtDate(drillDay.opened_at)}`}
|
||||
subtitle={`${drillOrders.length} παραγγελίες · ${fmtEUR(drillDay.revenue)} έσοδα`}
|
||||
onClose={() => setDrillId(null)}
|
||||
>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{drillOrders.map(o => {
|
||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
return (
|
||||
<TR key={o.id} striped>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_id}</TD>
|
||||
<TD mono>{fmtDate(o.opened_at)} {fmtTime(o.opened_at)}</TD>
|
||||
<TD mono>{o.closed_at ? fmtTime(o.closed_at) : '—'}</TD>
|
||||
<TD mono align="right" className="font-semibold">{fmtEUR(total)}</TD>
|
||||
<TD><StatusBadge status={o.status} /></TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</DrillDownModal>
|
||||
<WorkDayModal
|
||||
day={drillDay}
|
||||
onClose={() => setDrillDay(null)}
|
||||
onDeleteShift={s => setDeleteShift(s)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteDay && (
|
||||
<DeleteConfirmModal
|
||||
title={`Διαγραφή Εργάσιμης Μέρας #${deleteDay.id}`}
|
||||
description={`Η εργάσιμη μέρα της ${fmtDate(deleteDay.opened_at)} θα διαγραφεί μόνιμα. Δεν έχει βάρδιες.`}
|
||||
onConfirm={() => deleteDayMutation.mutate(deleteDay.id)}
|
||||
onCancel={() => setDeleteDay(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteShift && (
|
||||
<DeleteConfirmModal
|
||||
title={`Διαγραφή Βάρδιας #${deleteShift.id}`}
|
||||
description={`Η βάρδια του ${deleteShift.waiter_name} (${fmtDateTime(deleteShift.started_at)}) και ΟΛΕΣ οι παραγγελίες της θα διαγραφούν μόνιμα.`}
|
||||
onConfirm={() => deleteShiftMutation.mutate(deleteShift.id)}
|
||||
onCancel={() => setDeleteShift(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
286
manager_dashboard/src/pages/reports/shared/OrderDetailModal.jsx
Normal file
286
manager_dashboard/src/pages/reports/shared/OrderDetailModal.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { fmtEUR, fmtDateTime } from './reportDesignTokens'
|
||||
|
||||
// ── Status badge ────────────────────────────────────────────────────────────
|
||||
const STATUS_META = {
|
||||
active: { label: 'Ενεργό', bg: '#eff6ff', text: '#1d4ed8' },
|
||||
paid: { label: 'Πληρωμένο', bg: '#f0fdf4', text: '#15803d' },
|
||||
cancelled: { label: 'Ακυρωμένο', bg: '#fef2f2', text: '#b91c1c' },
|
||||
closed: { label: 'Κλειστό (απλήρωτο)', bg: '#fffbeb', text: '#b45309' },
|
||||
open: { label: 'Ανοιχτή', bg: '#eff6ff', text: '#1d4ed8' },
|
||||
partially_paid: { label: 'Μερική πλ.', bg: '#fffbeb', text: '#b45309' },
|
||||
}
|
||||
function StatusPill({ status }) {
|
||||
const m = STATUS_META[status] || { label: status, bg: '#f9fafb', text: '#374151' }
|
||||
return (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 20, background: m.bg, color: m.text }}>
|
||||
{m.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Payment method chip ─────────────────────────────────────────────────────
|
||||
const PAY_METHOD_STYLES = {
|
||||
cash: { label: 'ΜΕΤΡΗΤΑ', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
|
||||
card: { label: 'ΚΑΡΤΑ', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
|
||||
transfer: { label: 'ΤΡΑΠΕΖΙΚΗ', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
|
||||
treat: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
|
||||
comp: { label: 'ΚΕΡΑΣΤΗΚΕ', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
|
||||
}
|
||||
function PayMethodChip({ method }) {
|
||||
if (!method) return <span style={{ color: '#b8bdc4', fontSize: 12 }}>—</span>
|
||||
const s = PAY_METHOD_STYLES[method] || { label: method.toUpperCase(), color: '#374151', bg: '#f3f4f6', border: '#d1d5db' }
|
||||
return (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 6, background: s.bg, color: s.color, border: `1px solid ${s.border}`, letterSpacing: 0.3, whiteSpace: 'nowrap' }}>
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit event labels ──────────────────────────────────────────────────────
|
||||
const EVENT_LABELS = {
|
||||
ORDER_OPENED: { label: 'Άνοιγμα', bg: '#eff6ff', text: '#1d4ed8' },
|
||||
ITEMS_ADDED: { label: 'Προσθήκη', bg: '#eff6ff', text: '#1d4ed8' },
|
||||
PAYMENT: { label: 'Πληρωμή', bg: '#f0fdf4', text: '#15803d' },
|
||||
PAYMENT_OFFLINE: { label: 'Πληρωμή (Offline)', bg: '#fffbeb', text: '#b45309' },
|
||||
ORDER_CLOSED: { label: 'Κλείσιμο', bg: '#f9fafb', text: '#374151' },
|
||||
ORDER_CANCELLED: { label: 'Ακύρωση', bg: '#fef2f2', text: '#b91c1c' },
|
||||
ITEM_CANCELLED: { label: 'Ακύρωση αντ.', bg: '#fef2f2', text: '#b91c1c' },
|
||||
}
|
||||
|
||||
function AuditTimeline({ logs, itemsById }) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '16px 0' }}>Δεν υπάρχουν εγγραφές</p>
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{logs.map(log => {
|
||||
const meta = EVENT_LABELS[log.event_type] || { label: log.event_type, bg: '#f9fafb', text: '#374151' }
|
||||
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||
const displayTime = log.offline_at ? fmtDateTime(log.offline_at) : fmtDateTime(log.created_at)
|
||||
|
||||
let itemNames = []
|
||||
if (log.item_ids && itemsById) {
|
||||
try {
|
||||
const ids = JSON.parse(log.item_ids)
|
||||
itemNames = ids.map(id => {
|
||||
const item = itemsById[id]
|
||||
return item ? `${item.product?.name ?? `#${item.product_id}`} ×${item.quantity}` : null
|
||||
}).filter(Boolean)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={log.id} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '8px 0',
|
||||
borderBottom: '1px solid #f4f4f2',
|
||||
background: isDuplicate ? '#fef2f2' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flexShrink: 0, paddingTop: 2 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 10, background: isDuplicate ? '#fecaca' : meta.bg, color: isDuplicate ? '#b91c1c' : meta.text }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
{isDuplicate && <div style={{ fontSize: 10, color: '#dc2626', fontWeight: 700, marginTop: 2 }}>ΔΙΠΛΗ</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: '#374151', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span>{log.waiter_name ?? (log.waiter_id ? `#${log.waiter_id}` : '—')}</span>
|
||||
{log.amount != null && (
|
||||
<span style={{ fontWeight: 700, color: isDuplicate ? '#dc2626' : '#2f9e5e' }}>
|
||||
{fmtEUR(log.amount)}
|
||||
</span>
|
||||
)}
|
||||
{log.payment_method && <PayMethodChip method={log.payment_method} />}
|
||||
</div>
|
||||
{itemNames.length > 0 && (
|
||||
<div style={{ marginTop: 3, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{itemNames.map((name, i) => (
|
||||
<span key={i} style={{ fontSize: 11, background: '#f3f4f6', color: '#374151', padding: '1px 6px', borderRadius: 4 }}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', textAlign: 'right', flexShrink: 0 }}>
|
||||
{displayTime}
|
||||
{log.offline_at && <div style={{ fontSize: 10, color: '#f59e0b' }}>offline</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Items tab ───────────────────────────────────────────────────────────────
|
||||
function ItemsTab({ order }) {
|
||||
const itemsFiltered = order.items || []
|
||||
const billableItems = itemsFiltered.filter(i => i.status !== 'cancelled')
|
||||
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = billableItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
|
||||
if (itemsFiltered.length === 0) {
|
||||
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>Κανένα αντικείμενο</p>
|
||||
}
|
||||
|
||||
// Column widths: [item info] [ordered by] [paid by] [pay type] [total]
|
||||
const GRID = '1fr 150px 150px 110px 80px'
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 10, border: '1px solid #edeff1', overflow: 'hidden', marginBottom: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: GRID, background: '#f9fafb', padding: '6px 14px', fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.4, gap: 12 }}>
|
||||
<span>Προϊόν</span>
|
||||
<span>Παρήγγειλε</span>
|
||||
<span>Πλήρωσε</span>
|
||||
<span>Τρόπος</span>
|
||||
<span style={{ textAlign: 'right' }}>Σύνολο</span>
|
||||
</div>
|
||||
|
||||
{itemsFiltered.map(item => {
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
return (
|
||||
<div key={item.id} style={{
|
||||
display: 'grid', gridTemplateColumns: GRID, gap: 12,
|
||||
padding: '9px 14px', borderTop: '1px solid #f4f4f2',
|
||||
opacity: isCancelled ? 0.45 : 1,
|
||||
background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white',
|
||||
}}>
|
||||
{/* Item info */}
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none' }}>
|
||||
{item.product?.name ?? `#${item.product_id}`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: '#8a9099', fontFamily: 'ui-monospace,monospace' }}>×{item.quantity}</span>
|
||||
<span style={{ fontSize: 12, color: '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(item.unit_price)}</span>
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ordered by */}
|
||||
<div style={{ fontSize: 11, paddingTop: 2 }}>
|
||||
<div style={{ fontWeight: 600, color: '#374151' }}>{item.added_by_name ?? '—'}</div>
|
||||
{item.added_at && <div style={{ color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(item.added_at)}</div>}
|
||||
</div>
|
||||
|
||||
{/* Paid by */}
|
||||
<div style={{ fontSize: 11, paddingTop: 2 }}>
|
||||
{item.paid_by_name
|
||||
? <>
|
||||
<div style={{ fontWeight: 600, color: '#15803d' }}>{item.paid_by_name}</div>
|
||||
{item.paid_at && <div style={{ color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(item.paid_at)}</div>}
|
||||
</>
|
||||
: <span style={{ color: '#b8bdc4' }}>—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Pay type */}
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
<PayMethodChip method={item.payment_method} />
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
|
||||
{fmtEUR(item.unit_price * item.quantity)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Totals footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 14px', background: '#f9fafb', borderTop: '1px solid #edeff1', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{closedTotal > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#b45309' }}>
|
||||
Απλήρωτα κλειστά: <span style={{ fontWeight: 700 }}>{fmtEUR(closedTotal)}</span>
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.4 }}>Σύνολο</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: '#111315', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main modal ──────────────────────────────────────────────────────────────
|
||||
export default function OrderDetailModal({ order, onClose }) {
|
||||
const [tab, setTab] = useState('items')
|
||||
if (!order) return null
|
||||
|
||||
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
|
||||
|
||||
const TABS = [
|
||||
{ key: 'items', label: `Αντικείμενα (${(order.items || []).length})` },
|
||||
{ key: 'audit', label: 'Ιστορικό Συναλλαγών' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{ background: 'white', borderRadius: 20, width: '100%', maxWidth: 1032, maxHeight: '92vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.25)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
Παραγγελία #{order.id}
|
||||
<StatusPill status={order.status} />
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 3 }}>
|
||||
{order.table_name ? `Τραπέζι ${order.table_name}` : order.table_id ? `Τραπέζι #${order.table_id}` : ''}
|
||||
{order.notes && <span style={{ marginLeft: 8, color: '#d97706' }}>· Σημ: {order.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#8a9099', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Opener / closer strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: '#edeff1', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
{[
|
||||
{ label: 'Άνοιξε', name: order.opened_by_name, time: order.opened_at },
|
||||
{ label: 'Έκλεισε', name: order.closed_by_name, time: order.closed_at },
|
||||
].map(cell => (
|
||||
<div key={cell.label} style={{ background: 'white', padding: '10px 20px' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 }}>{cell.label}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: cell.name ? '#111315' : '#b8bdc4' }}>{cell.name ?? '—'}</div>
|
||||
{cell.time && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{fmtDateTime(cell.time)}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #edeff1', flexShrink: 0, padding: '0 24px' }}>
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
padding: '10px 16px', fontSize: 12, fontWeight: 600,
|
||||
color: tab === t.key ? '#111315' : '#8a9099',
|
||||
borderBottom: tab === t.key ? '2px solid #111315' : '2px solid transparent',
|
||||
background: 'none', border: 'none', borderBottom: tab === t.key ? '2px solid #111315' : '2px solid transparent',
|
||||
cursor: 'pointer', transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{tab === 'items' && <ItemsTab order={order} />}
|
||||
{tab === 'audit' && <AuditTimeline logs={order.audit_logs} itemsById={itemsById} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
Normal file
263
manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { fmtEUR, fmtDateTime, fmtTime, fmtDate } from './reportDesignTokens'
|
||||
|
||||
// ── colour rules ────────────────────────────────────────────────────────────
|
||||
// Given the shift's waiter_id and one item, return { key, label, colors }
|
||||
function classify(item, shiftWaiterId) {
|
||||
const orderedByMe = item.added_by_id === shiftWaiterId
|
||||
const paidToMe = item.paid_by_id === shiftWaiterId
|
||||
const isPaid = item.status === 'paid'
|
||||
|
||||
if (orderedByMe && paidToMe && isPaid)
|
||||
return { key: 'both', label: 'Παρήγγειλε + Πληρώθηκε', dot: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' }
|
||||
if (!orderedByMe && paidToMe && isPaid)
|
||||
return { key: 'paid', label: 'Πληρώθηκε (άλλος παρήγγειλε)', dot: '#2563eb', bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' }
|
||||
if (orderedByMe && isPaid && !paidToMe)
|
||||
return { key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)', dot: '#ca8a04', bg: '#fefce8', border: '#fef08a', text: '#854d0e' }
|
||||
if (orderedByMe && !isPaid)
|
||||
return { key: 'unpaid', label: 'Παρήγγειλε (απλήρωτο)', dot: '#ea580c', bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' }
|
||||
// fallback: paid to me but status not 'paid' — data anomaly
|
||||
return { key: 'anomaly', label: 'Ανωμαλία δεδομένων', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ key: 'all', label: 'Όλα' },
|
||||
{ key: 'both', label: 'Παρήγγειλε + Πληρώθηκε' },
|
||||
{ key: 'paid', label: 'Πληρώθηκε (ξένη παραγγελία)' },
|
||||
{ key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' },
|
||||
{ key: 'unpaid', label: 'Απλήρωτα' },
|
||||
]
|
||||
|
||||
function fmtMins(mins) {
|
||||
if (mins == null) return '—'
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
|
||||
}
|
||||
|
||||
function ItemRow({ item, shiftWaiterId }) {
|
||||
const c = classify(item, shiftWaiterId)
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '7px 14px',
|
||||
borderTop: '1px solid #f4f4f2', background: c.bg,
|
||||
}}>
|
||||
{/* colour dot */}
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: c.dot, flexShrink: 0, marginTop: 5 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
|
||||
{item.product_name}
|
||||
<span style={{ fontWeight: 400, color: '#8a9099', marginLeft: 4 }}>×{item.quantity}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#111315', flexShrink: 0 }}>{fmtEUR(item.subtotal)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '2px 12px', marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Παρήγγειλε: <span style={{ fontWeight: 600, color: '#374151' }}>{item.added_by_name ?? '—'}</span>
|
||||
{item.added_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.added_at)}</span> : null}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Πληρώθηκε: <span style={{ fontWeight: 600, color: item.paid_by_name ? '#374151' : '#9ca3af' }}>{item.paid_by_name ?? '—'}</span>
|
||||
{item.paid_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.paid_at)}</span> : null}
|
||||
{item.payment_method ? <span style={{ color: '#9ca3af' }}> ({item.payment_method})</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4,
|
||||
background: c.border, color: c.text, border: `1px solid ${c.border}`,
|
||||
}}>{c.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrderGroup({ order, shiftWaiterId, activeFilter }) {
|
||||
const visibleItems = order.items.filter(item => {
|
||||
if (activeFilter === 'all') return true
|
||||
return classify(item, shiftWaiterId).key === activeFilter
|
||||
})
|
||||
if (visibleItems.length === 0) return null
|
||||
|
||||
const groupTotal = visibleItems
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((s, i) => s + i.subtotal, 0)
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 10, border: '1px solid #edeff1', marginBottom: 8, overflow: 'hidden' }}>
|
||||
{/* Order header */}
|
||||
<div style={{ padding: '8px 14px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
|
||||
Παραγγελία #{order.order_id}
|
||||
{order.table_name && <span style={{ color: '#8a9099', fontWeight: 400, marginLeft: 6 }}>· Τραπέζι {order.table_name}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{order.opener_name && (
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Άνοιξε: <span style={{ fontWeight: 600, color: '#374151' }}>{order.opener_name}</span>
|
||||
{order.opened_at ? <span style={{ color: '#9ca3af' }}> {fmtTime(order.opened_at)}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{order.closer_name && (
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Έκλεισε: <span style={{ fontWeight: 600, color: '#374151' }}>{order.closer_name}</span>
|
||||
{order.closed_at ? <span style={{ color: '#9ca3af' }}> {fmtTime(order.closed_at)}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{groupTotal > 0 && (
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#2f9e5e' }}>{fmtEUR(groupTotal)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleItems.map(item => (
|
||||
<ItemRow key={item.id} item={item} shiftWaiterId={shiftWaiterId} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
|
||||
const { data: summary, isLoading, isError } = useQuery({
|
||||
queryKey: ['shift-detail', shiftId],
|
||||
queryFn: () => client.get(`/api/shifts/${shiftId}/summary`).then(r => r.data),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
// Count items per filter key for badge counts
|
||||
const counts = { all: 0, both: 0, paid: 0, ordered: 0, unpaid: 0 }
|
||||
const waiterIdToUse = shiftWaiterId ?? summary?.waiter_id
|
||||
if (summary?.orders) {
|
||||
for (const o of summary.orders) {
|
||||
for (const item of o.items) {
|
||||
counts.all++
|
||||
const key = classify(item, waiterIdToUse).key
|
||||
if (key in counts) counts[key]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 900,
|
||||
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#111315' }}>Λεπτομέρειες Βάρδιας</div>
|
||||
{summary && (
|
||||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>
|
||||
{summary.waiter_name} · {fmtDate(summary.started_at)} · {fmtTime(summary.started_at)}–{summary.ended_at ? fmtTime(summary.ended_at) : 'ενεργή'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#8a9099', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div style={{ padding: 40, textAlign: 'center', color: '#8a9099' }}>Φόρτωση…</div>}
|
||||
{isError && <div style={{ padding: 40, textAlign: 'center', color: '#dc2626' }}>Σφάλμα φόρτωσης δεδομένων</div>}
|
||||
|
||||
{summary && (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
{[
|
||||
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
|
||||
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
|
||||
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
|
||||
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
|
||||
].map(k => (
|
||||
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Colour legend */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ dot: '#16a34a', label: 'Παρήγγειλε + Πληρώθηκε' },
|
||||
{ dot: '#2563eb', label: 'Πληρώθηκε (παρήγγηλε άλλος)' },
|
||||
{ dot: '#ca8a04', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' },
|
||||
{ dot: '#ea580c', label: 'Παρήγγειλε (απλήρωτο)' },
|
||||
{ dot: '#dc2626', label: 'Πρόβλημα Δεδομένων' },
|
||||
].map(l => (
|
||||
<div key={l.dot} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.dot, flexShrink: 0 }} />
|
||||
{l.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '10px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, overflowX: 'auto' }}>
|
||||
{FILTER_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setActiveFilter(opt.key)}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
border: activeFilter === opt.key ? '1.5px solid #3758c9' : '1.5px solid #e5e7eb',
|
||||
background: activeFilter === opt.key ? '#eff6ff' : 'white',
|
||||
color: activeFilter === opt.key ? '#3758c9' : '#5a6169',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{counts[opt.key] > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 5, padding: '1px 5px', borderRadius: 8, fontSize: 10,
|
||||
background: activeFilter === opt.key ? '#3758c9' : '#e5e7eb',
|
||||
color: activeFilter === opt.key ? 'white' : '#5a6169',
|
||||
}}>
|
||||
{counts[opt.key]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Orders + items */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 24px' }}>
|
||||
{summary.orders.length === 0 ? (
|
||||
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
Δεν υπάρχουν αντικείμενα για αυτή τη βάρδια
|
||||
</p>
|
||||
) : (
|
||||
summary.orders.map(o => (
|
||||
<OrderGroup key={o.order_id} order={o} shiftWaiterId={waiterIdToUse} activeFilter={activeFilter} />
|
||||
))
|
||||
)}
|
||||
{summary.orders.length > 0 &&
|
||||
!summary.orders.some(o => o.items.some(i =>
|
||||
activeFilter === 'all' || classify(i, waiterIdToUse).key === activeFilter
|
||||
)) && (
|
||||
<p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
Δεν υπάρχουν αντικείμενα για αυτό το φίλτρο
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { UserRoundX, ChevronRight } from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { UserRoundX, ChevronRight, ExternalLink, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import ShiftDetailModal from '../shared/ShiftDetailModal'
|
||||
import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
|
||||
@@ -9,18 +12,22 @@ import StatCard from '../shared/StatCard'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtDateTime, fmtDuration } from '../shared/reportDesignTokens'
|
||||
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10) }
|
||||
function monthAgo() {
|
||||
const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function ShiftsOverview() {
|
||||
export default function ShiftsOverview({ onNavigate } = {}) {
|
||||
const [waiterId, setWaiterId] = useState('all')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const [detailShift, setDetailShift] = useState(null) // { id, waiter_id }
|
||||
const [deleteShift, setDeleteShift] = useState(null) // shift object to delete
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: waitersData } = useQuery({
|
||||
queryKey: ['meta-waiters'],
|
||||
@@ -28,6 +35,14 @@ export default function ShiftsOverview() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const { data: bdData } = useQuery({
|
||||
queryKey: ['business-days-list'],
|
||||
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const bdById = Object.fromEntries((bdData?.business_days || []).map(bd => [String(bd.id), bd]))
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['shifts', waiterId, from, to],
|
||||
queryFn: () => client.get('/api/reports/shifts', {
|
||||
@@ -37,7 +52,21 @@ export default function ShiftsOverview() {
|
||||
to: to + 'T23:59:59',
|
||||
},
|
||||
}).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const deleteShiftMutation = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/shifts/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Η βάρδια διαγράφηκε')
|
||||
qc.invalidateQueries({ queryKey: ['shifts'] })
|
||||
qc.invalidateQueries({ queryKey: ['business-days-list'] })
|
||||
setDeleteShift(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής βάρδιας')
|
||||
setDeleteShift(null)
|
||||
},
|
||||
})
|
||||
|
||||
const waiterOptions = [
|
||||
@@ -94,7 +123,7 @@ export default function ShiftsOverview() {
|
||||
<TH align="right">Εισπράχθηκαν</TH>
|
||||
<TH align="right">Οφείλει</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH className="w-10" />
|
||||
<TH className="w-28" />
|
||||
</THead>
|
||||
<tbody>
|
||||
{shifts.map(s => {
|
||||
@@ -119,14 +148,49 @@ export default function ShiftsOverview() {
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
|
||||
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||
<TD align="right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDetailShift({ id: s.id, waiter_id: s.waiter_id }) }}
|
||||
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</button>
|
||||
{!s.is_active && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteShift(s) }}
|
||||
className="rounded border border-red-200 bg-white p-0.5 text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Διαγραφή βάρδιας"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className={`h-4 w-4 text-slate-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
</TD>
|
||||
</TR>,
|
||||
isOpen && (
|
||||
<tr key={`${s.id}-detail`}>
|
||||
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
||||
<div className="text-[12px] text-slate-500">
|
||||
Εργάσιμη Μέρα ID: {s.business_day_id ?? '—'} · Σημειώσεις: {s.notes || '—'}
|
||||
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
||||
<span>
|
||||
{'Εργάσιμη Μέρα: '}
|
||||
{s.business_day_id ? (() => {
|
||||
const bd = bdById[String(s.business_day_id)]
|
||||
const label = bd
|
||||
? `#${s.business_day_id} · ${fmtDate(bd.opened_at)} ${fmtTime(bd.opened_at)}`
|
||||
: `#${s.business_day_id}`
|
||||
return onNavigate ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onNavigate({ parent: 'restaurant', sub: 'orders', initialBusinessDayId: s.business_day_id }) }}
|
||||
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 hover:underline font-medium"
|
||||
>
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
) : <span>{label}</span>
|
||||
})() : '—'}
|
||||
</span>
|
||||
<span>Σημειώσεις: {s.notes || '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -138,6 +202,23 @@ export default function ShiftsOverview() {
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{detailShift && (
|
||||
<ShiftDetailModal
|
||||
shiftId={detailShift.id}
|
||||
shiftWaiterId={detailShift.waiter_id}
|
||||
onClose={() => setDetailShift(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteShift && (
|
||||
<DeleteConfirmModal
|
||||
title={`Διαγραφή Βάρδιας #${deleteShift.id}`}
|
||||
description={`Η βάρδια του ${deleteShift.waiter_name} (${fmtDateTime(deleteShift.started_at)} – ${fmtDateTime(deleteShift.ended_at)}) θα διαγραφεί μόνιμα.`}
|
||||
onConfirm={() => deleteShiftMutation.mutate(deleteShift.id)}
|
||||
onCancel={() => setDeleteShift(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const LABELS = {
|
||||
partially_paid: 'Μερική πληρωμή',
|
||||
paid: 'Πληρώθηκε',
|
||||
closed: 'Κλειστό',
|
||||
closed_item: 'Κλειστό (απλήρωτο)',
|
||||
'force-closed': 'Αναγκαστικό κλείσιμο',
|
||||
cancelled: 'Ακυρώθηκε',
|
||||
completed: 'Ολοκληρώθηκε',
|
||||
|
||||
101
manager_dashboard/src/ui/DeleteConfirmModal.jsx
Normal file
101
manager_dashboard/src/ui/DeleteConfirmModal.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Two-step delete confirmation:
|
||||
* Step 1 — "Are you sure?" with a description of what will be deleted.
|
||||
* Step 2 — Type "DELETE" to confirm.
|
||||
*
|
||||
* Props:
|
||||
* title — e.g. "Διαγραφή Βάρδιας #12"
|
||||
* description — e.g. "Η βάρδια του Νίκου θα διαγραφεί μόνιμα."
|
||||
* onConfirm — called when the user completes both steps
|
||||
* onCancel — called when the user dismisses
|
||||
*/
|
||||
export default function DeleteConfirmModal({ title, description, onConfirm, onCancel }) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [typed, setTyped] = useState('')
|
||||
|
||||
const canDelete = typed.trim().toUpperCase() === 'DELETE'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 px-6 pt-6 pb-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100">
|
||||
<AlertTriangle size={18} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-bold text-gray-900">{title}</div>
|
||||
<div className="mt-1 text-sm text-gray-500">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<p className="text-sm text-red-700 font-medium bg-red-50 rounded-lg px-3 py-2">
|
||||
Αυτή η ενέργεια είναι μη αναστρέψιμη.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-lg border border-gray-200 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-bold text-white hover:bg-red-700"
|
||||
>
|
||||
Ναι, συνέχεια →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Για επιβεβαίωση πληκτρολογήστε <span className="font-mono font-bold text-red-600">DELETE</span>
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={e => setTyped(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && canDelete && onConfirm()}
|
||||
placeholder="DELETE"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-lg border border-gray-200 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!canDelete}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Διαγραφή
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
manager_dashboard/src/ui/PaymentMethodModal.jsx
Normal file
54
manager_dashboard/src/ui/PaymentMethodModal.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const METHODS = [
|
||||
{ key: 'cash', label: 'ΜΕΤΡΗΤΑ', icon: '💵', color: '#15803d', bg: '#f0fdf4', border: '#86efac' },
|
||||
{ key: 'card', label: 'ΚΑΡΤΑ', icon: '💳', color: '#1d4ed8', bg: '#eff6ff', border: '#93c5fd' },
|
||||
{ key: 'transfer', label: 'ΤΡΑΠΕΖΙΚΗ ΜΕΤΑΦΟΡΑ', icon: '🏦', color: '#6d28d9', bg: '#f5f3ff', border: '#c4b5fd' },
|
||||
{ key: 'treat', label: 'ΚΕΡΑΣΤΗΚΕ', icon: '🎁', color: '#b45309', bg: '#fffbeb', border: '#fcd34d' },
|
||||
]
|
||||
|
||||
export default function PaymentMethodModal({ onSelect, onCancel, title = 'Τρόπος Πληρωμής;' }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl bg-white shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<span className="text-base font-bold text-gray-800">{title}</span>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Method buttons */}
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
{METHODS.map(m => (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => onSelect(m.key)}
|
||||
style={{ background: m.bg, borderColor: m.border, color: m.color }}
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-xl border-2 py-5 font-bold text-sm transition-transform active:scale-95 hover:opacity-90"
|
||||
>
|
||||
<span className="text-2xl">{m.icon}</span>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full rounded-lg border border-gray-200 py-2 text-sm text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export const STATUS_STYLES = {
|
||||
open: { bg: 'bg-sky-50', text: 'text-sky-700', ring: 'ring-sky-200', dot: 'bg-sky-500' },
|
||||
partially_paid: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
|
||||
cancelled: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
|
||||
closed_item: { bg: 'bg-amber-50', text: 'text-amber-800', ring: 'ring-amber-200', dot: 'bg-amber-500' },
|
||||
failed: { bg: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-200', dot: 'bg-rose-500' },
|
||||
success: { bg: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-200', dot: 'bg-emerald-500' },
|
||||
free: { bg: 'bg-slate-100', text: 'text-slate-600', ring: 'ring-slate-200', dot: 'bg-slate-400' },
|
||||
|
||||
27
waiter_pwa/package-lock.json
generated
27
waiter_pwa/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "waiter_pwa",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
@@ -2250,6 +2251,32 @@
|
||||
"string.prototype.matchall": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz",
|
||||
"integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz",
|
||||
"integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import useAuthStore from './store/authStore'
|
||||
import useShiftStore from './store/shiftStore'
|
||||
import useThemeStore from './store/themeStore'
|
||||
@@ -309,10 +310,21 @@ function ColourLoader() {
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Login guard — redirect to /tables if already authenticated ───────────────
|
||||
|
||||
function LoginGuard({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
if (token) return <Navigate to="/tables" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeApplier />
|
||||
<ColourLoader />
|
||||
@@ -322,7 +334,7 @@ export default function App() {
|
||||
<NotificationProvider>
|
||||
<ConnectionLostModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
@@ -335,5 +347,6 @@ export default function App() {
|
||||
</NotificationProvider>
|
||||
</SSEProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import useConnectionStore from '../store/connectionStore'
|
||||
import client from '../api/client'
|
||||
import { useSSEContext } from '../context/SSEContext'
|
||||
|
||||
const RETRY_INTERVAL = 10_000 // 10s auto-retry while modal is open in Wait mode
|
||||
const RETRY_INTERVAL = 10_000
|
||||
|
||||
export default function ConnectionLostModal() {
|
||||
const { status, setOnline, enterEmergency } = useConnectionStore()
|
||||
@@ -11,13 +11,13 @@ export default function ConnectionLostModal() {
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const retryRef = useRef(null)
|
||||
|
||||
const isVisible = status === 'lost'
|
||||
const isReconnecting = status === 'reconnecting'
|
||||
const isLost = status === 'lost'
|
||||
|
||||
async function tryReconnect() {
|
||||
setRetrying(true)
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
// Server is back
|
||||
setOnline()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
@@ -28,18 +28,53 @@ export default function ConnectionLostModal() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry every 10s while modal is open
|
||||
// Auto-retry every 10s while the full "lost" modal is open
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
clearInterval(retryRef.current)
|
||||
return
|
||||
}
|
||||
if (!isLost) { clearInterval(retryRef.current); return }
|
||||
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
|
||||
return () => clearInterval(retryRef.current)
|
||||
}, [isVisible])
|
||||
}, [isLost])
|
||||
|
||||
if (!isVisible) return null
|
||||
if (!isReconnecting && !isLost) return null
|
||||
|
||||
// ── Grace-period spinner ───────────────────────────────────────────────────
|
||||
if (isReconnecting) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1e293b',
|
||||
border: '2px solid #334155',
|
||||
borderRadius: 20,
|
||||
padding: '32px 28px',
|
||||
maxWidth: 340, width: '100%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Spinning ring */}
|
||||
<div style={{
|
||||
width: 52, height: 52, margin: '0 auto 20px',
|
||||
border: '4px solid #334155',
|
||||
borderTopColor: 'var(--accent, #f97316)',
|
||||
borderRadius: '50%',
|
||||
animation: 'gate-spin 0.8s linear infinite',
|
||||
}} />
|
||||
<p style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>
|
||||
Επανασύνδεση…
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: '#64748b', lineHeight: 1.6 }}>
|
||||
Προσπαθώ να φτάσω στον server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Full "lost" modal ──────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
@@ -58,33 +93,23 @@ export default function ConnectionLostModal() {
|
||||
}}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
|
||||
<p style={{
|
||||
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<p style={{ fontSize: 20, fontWeight: 700, color: '#f1f5f9', marginBottom: 10 }}>
|
||||
Χάθηκε η σύνδεση με τον Manager
|
||||
</p>
|
||||
|
||||
<p style={{
|
||||
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
|
||||
marginBottom: 28,
|
||||
}}>
|
||||
<p style={{ fontSize: 14, color: '#94a3b8', lineHeight: 1.6, marginBottom: 28 }}>
|
||||
Δεν μπορώ να φτάσω στον server.{'\n'}
|
||||
Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'}
|
||||
για να συνεχίσεις με τοπικά δεδομένα.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={enterEmergency}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 48, borderRadius: 12, border: 'none',
|
||||
flex: 1, height: 48, borderRadius: 12, border: 'none',
|
||||
background: '#dc2626', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
EMERGENCY MODE
|
||||
|
||||
@@ -281,7 +281,6 @@ function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -302,8 +301,9 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
<div style={{ marginTop: 5 }}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
|
||||
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
|
||||
{/* Always reserve amount height — invisible when free */}
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,6 @@ function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -345,9 +344,9 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
{/* separator dot */}
|
||||
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
|
||||
|
||||
{/* amount */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
|
||||
{/* amount — always reserve space, invisible when free */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
|
||||
{/* flags up to 3 + +N */}
|
||||
@@ -365,7 +364,6 @@ function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
const showWaiters = !isFree && waiterObjects.length > 0
|
||||
|
||||
return (
|
||||
@@ -404,12 +402,10 @@ function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
</div>
|
||||
|
||||
{/* right: amount — top-aligned */}
|
||||
{showAmount && (
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{/* right: amount — always reserve space, invisible when free */}
|
||||
<div style={{ flexShrink: 0, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* flag chips row — right-aligned */}
|
||||
@@ -479,8 +475,8 @@ function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
|
||||
<div style={{ marginTop: 10, visibility: isFree ? 'hidden' : 'visible' }}>
|
||||
<Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import { useSSE } from '../hooks/useSSE'
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
import { flushOfflinePayments } from '../services/offlinePayments'
|
||||
import { invalidateProductCache } from '../hooks/useProductCache'
|
||||
|
||||
const SSEContext = createContext(null)
|
||||
|
||||
@@ -17,6 +19,7 @@ const HEARTBEAT_INTERVAL = 30_000
|
||||
export function SSEProvider({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
const { setLost, setOnline } = useConnectionStore()
|
||||
const queryClient = useQueryClient()
|
||||
const sseAlive = useRef(false)
|
||||
const heartbeatRef = useRef(null)
|
||||
|
||||
@@ -97,10 +100,14 @@ export function SSEProvider({ children }) {
|
||||
await snapshotTables()
|
||||
break
|
||||
}
|
||||
case 'products_changed': {
|
||||
invalidateProductCache(queryClient)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [snapshotTables])
|
||||
}, [snapshotTables, queryClient])
|
||||
|
||||
// ── SSE connection lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
@@ -175,6 +182,32 @@ export function SSEProvider({ children }) {
|
||||
return () => window.removeEventListener('backend-offline', onBackendOffline)
|
||||
}, [])
|
||||
|
||||
// ── Wake-up handshake — fires when tab/app returns from background ────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
async function onVisible() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
const currentStatus = useConnectionStore.getState().status
|
||||
if (currentStatus === 'lost' || currentStatus === 'emergency' || currentStatus === 'reconnecting') {
|
||||
setOnlineRef.current()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
} else if (!sseAlive.current) {
|
||||
// SSE dropped silently while sleeping — re-establish quietly
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
}
|
||||
} catch {
|
||||
if (!sseAlive.current) setLostRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [token, reconnect, fullRefresh])
|
||||
|
||||
// ── Initial snapshot on login ─────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,9 +7,17 @@ import Dexie from 'dexie'
|
||||
const db = new Dexie('pos_snapshot')
|
||||
|
||||
db.version(1).stores({
|
||||
tables: 'id, group_id, is_active', // TableOut snapshots
|
||||
orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots
|
||||
offline_payments: '++localId, uuid, synced', // queued emergency payments
|
||||
tables: 'id, group_id, is_active',
|
||||
orders: 'id, table_id, status',
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
})
|
||||
|
||||
db.version(2).stores({
|
||||
tables: 'id, group_id, is_active',
|
||||
orders: 'id, table_id, status',
|
||||
offline_payments: '++localId, uuid, synced',
|
||||
products: 'id, category_id, is_available',
|
||||
categories: 'id, parent_id',
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
69
waiter_pwa/src/hooks/useProductCache.js
Normal file
69
waiter_pwa/src/hooks/useProductCache.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
|
||||
export const PRODUCTS_KEY = ['products']
|
||||
export const CATEGORIES_KEY = ['categories']
|
||||
|
||||
async function fetchAndCacheProducts() {
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
client.get('/api/products/'),
|
||||
client.get('/api/products/categories'),
|
||||
])
|
||||
const products = prodRes.data
|
||||
const categories = catRes.data
|
||||
|
||||
// Write to IndexedDB in the background — don't await so UI isn't blocked
|
||||
db.products.bulkPut(products).catch(() => {})
|
||||
db.categories.bulkPut(categories).catch(() => {})
|
||||
|
||||
return { products, categories }
|
||||
}
|
||||
|
||||
async function loadFromCache() {
|
||||
const [products, categories] = await Promise.all([
|
||||
db.products.toArray(),
|
||||
db.categories.toArray(),
|
||||
])
|
||||
if (products.length === 0 && categories.length === 0) return null
|
||||
return { products, categories }
|
||||
}
|
||||
|
||||
export function useProductCache() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: PRODUCTS_KEY,
|
||||
queryFn: fetchAndCacheProducts,
|
||||
// Serve stale data instantly — products don't change every second
|
||||
staleTime: 5 * 60 * 1000, // 5 min before background re-fetch
|
||||
gcTime: 60 * 60 * 1000, // keep in memory for 1 hour
|
||||
placeholderData: undefined,
|
||||
})
|
||||
|
||||
// On mount, if the query has no data yet, seed it from IndexedDB immediately
|
||||
// so the UI renders without waiting for the network round-trip
|
||||
useEffect(() => {
|
||||
const cached = queryClient.getQueryData(PRODUCTS_KEY)
|
||||
if (cached) return
|
||||
|
||||
loadFromCache().then(idbData => {
|
||||
if (idbData) {
|
||||
// Set as placeholder — React Query will still fetch in background
|
||||
queryClient.setQueryData(PRODUCTS_KEY, idbData)
|
||||
}
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
return {
|
||||
products: query.data?.products ?? [],
|
||||
categories: query.data?.categories ?? [],
|
||||
isLoading: query.isLoading && !query.data,
|
||||
}
|
||||
}
|
||||
|
||||
// Call this from SSEContext when products_changed arrives
|
||||
export function invalidateProductCache(queryClient) {
|
||||
queryClient.invalidateQueries({ queryKey: PRODUCTS_KEY })
|
||||
}
|
||||
@@ -667,7 +667,14 @@ html, body {
|
||||
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
|
||||
.user-menu-item--disabled:hover { background: transparent; }
|
||||
.user-menu-item--danger { color: var(--danger); }
|
||||
.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
|
||||
.user-menu-item__icon {
|
||||
font-size: 17px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import ProductPicker from '../components/ProductPicker'
|
||||
import OrderDrawer from '../components/OrderDrawer'
|
||||
import client from '../api/client'
|
||||
import { useProductCache } from '../hooks/useProductCache'
|
||||
|
||||
export default function AddItemsPage() {
|
||||
const { tableId } = useParams()
|
||||
@@ -10,8 +11,8 @@ export default function AddItemsPage() {
|
||||
const isNewTable = searchParams.get('new') === '1'
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [categories, setCategories] = useState([])
|
||||
const [products, setProducts] = useState([])
|
||||
const { products, categories } = useProductCache()
|
||||
|
||||
const [cart, setCart] = useState([])
|
||||
const [orderId, setOrderId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
@@ -24,16 +25,9 @@ export default function AddItemsPage() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [catRes, prodRes, statusRes] = await Promise.all([
|
||||
client.get('/api/products/categories'),
|
||||
client.get('/api/products/'),
|
||||
client.get(`/api/tables/${tableId}/status`),
|
||||
])
|
||||
setCategories(catRes.data)
|
||||
setProducts(prodRes.data)
|
||||
const statusRes = await client.get(`/api/tables/${tableId}/status`)
|
||||
setOrderId(statusRes.data.active_order_id)
|
||||
|
||||
// Pre-populate cart from "order again" if present
|
||||
|
||||
@@ -762,9 +762,10 @@ function ZoneTab({ label, color, active, onClick }) {
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 12px', borderRadius: 20, border: 'none',
|
||||
padding: '8px 16px', borderRadius: 20,
|
||||
border: '2px solid transparent',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
fontWeight: 600, fontSize: 13,
|
||||
fontWeight: 600, fontSize: 14,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
|
||||
@@ -5,25 +5,49 @@ import { create } from 'zustand'
|
||||
*
|
||||
* States:
|
||||
* 'online' — server reachable, SSE connected, normal operation
|
||||
* 'lost' — server unreachable, modal shown (Wait / Emergency)
|
||||
* 'reconnecting' — connection blip detected; 5-second grace before showing full modal
|
||||
* 'lost' — grace period expired, modal shown (Wait / Emergency)
|
||||
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
|
||||
*/
|
||||
|
||||
const GRACE_MS = 5_000
|
||||
|
||||
const useConnectionStore = create((set, get) => ({
|
||||
status: 'online', // 'online' | 'lost' | 'emergency'
|
||||
lostAt: null, // Date when connection was lost
|
||||
status: 'online', // 'online' | 'reconnecting' | 'lost' | 'emergency'
|
||||
lostAt: null,
|
||||
_graceTimer: null,
|
||||
|
||||
setLost: () => {
|
||||
if (get().status === 'online') {
|
||||
set({ status: 'lost', lostAt: new Date() })
|
||||
const { status, _graceTimer } = get()
|
||||
// Already lost or in emergency — no-op
|
||||
if (status === 'lost' || status === 'emergency') return
|
||||
// Already in grace period — don't restart the timer
|
||||
if (status === 'reconnecting') return
|
||||
|
||||
// Start grace period
|
||||
const timer = setTimeout(() => {
|
||||
// Only escalate if we're still in reconnecting (not recovered in the meantime)
|
||||
if (get().status === 'reconnecting') {
|
||||
set({ status: 'lost', _graceTimer: null })
|
||||
}
|
||||
}, GRACE_MS)
|
||||
|
||||
set({ status: 'reconnecting', lostAt: new Date(), _graceTimer: timer })
|
||||
},
|
||||
|
||||
setOnline: () => set({ status: 'online', lostAt: null }),
|
||||
setOnline: () => {
|
||||
const { _graceTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'online', lostAt: null, _graceTimer: null })
|
||||
},
|
||||
|
||||
enterEmergency: () => set({ status: 'emergency' }),
|
||||
enterEmergency: () => {
|
||||
const { _graceTimer } = get()
|
||||
if (_graceTimer) clearTimeout(_graceTimer)
|
||||
set({ status: 'emergency', _graceTimer: null })
|
||||
},
|
||||
|
||||
// Called when server comes back while in emergency mode — triggers sync then go online
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null }),
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null, _graceTimer: null }),
|
||||
|
||||
isOnline: () => get().status === 'online',
|
||||
isLost: () => get().status === 'lost',
|
||||
|
||||
@@ -13,8 +13,8 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'TableServe',
|
||||
short_name: 'TableServe',
|
||||
name: 'Xenia',
|
||||
short_name: 'Xenia',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0f172a',
|
||||
|
||||
Reference in New Issue
Block a user