feat: Feature 2 — waiter order cancellation + report updates
Backend: - Add can_cancel_orders to User model and schema - Add global orders.waiter_cancellations_allowed setting (migration) - Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print - print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner - Fix cancellations_log: date filter, waiter filter, join syntax - shift/orders: add cancellations count and hours_worked per waiter - _enrich_shift: add cancellations count to shift data - Add cancel-permissions endpoint for PWA Manager dashboard: - Global cancel setting toggle in Settings > Operation > Shift Settings - Per-waiter can_cancel_orders checkbox in staff modal - Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage - ShiftsOverview: Ακυρώσεις column per shift - Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes, grouped/stacked switch, right X-axis for hours, full waiter name on hover - OrderHistory: cancelled items count column per order - WorkDaySummary drill-down: cancelled items column in orders tab Waiter PWA: - Replace 3 pills with CLEAR | ALL | ACTIONS - ACTIONS opens ItemActionModal for selected items - ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER - ActionsSheet: Cancel Παραγγελίας option (greyed if no permission) - CancelConfirmModal: requires confirmation before cancelling - TableListPage: cancel order from long-press quick modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -408,6 +408,9 @@ def _run_migrations():
|
|||||||
)""",
|
)""",
|
||||||
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
|
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
|
||||||
"ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Waiter cancellation permissions
|
||||||
|
"ALTER TABLE users ADD COLUMN can_cancel_orders INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_cancellations_allowed', 'false', CURRENT_TIMESTAMP)",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class User(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
# Phase 2B — payroll
|
# Phase 2B — payroll
|
||||||
hourly_rate = Column(Float, nullable=True)
|
hourly_rate = Column(Float, nullable=True)
|
||||||
|
# Waiter cancellation permission
|
||||||
|
can_cancel_orders = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||||
|
|||||||
@@ -42,12 +42,20 @@ class MoveItemsRequest(BaseModel):
|
|||||||
target_order_id: int
|
target_order_id: int
|
||||||
|
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis, print_cancellation_ticket
|
||||||
|
from models.settings import PosSettings
|
||||||
from services.sse_bus import broadcast_sync
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _waiter_can_cancel(user: User, db: Session) -> bool:
|
||||||
|
"""True only if both the global setting and the per-waiter flag are on."""
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
|
||||||
|
global_on = row is not None and row.value == "true"
|
||||||
|
return global_on and bool(user.can_cancel_orders)
|
||||||
|
|
||||||
|
|
||||||
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||||
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
|
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
|
||||||
if user.role in ("manager", "sysadmin"):
|
if user.role in ("manager", "sysadmin"):
|
||||||
@@ -79,6 +87,25 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
|||||||
|
|
||||||
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
|
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cancel-permissions")
|
||||||
|
def cancel_permissions(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Return whether the current user is allowed to cancel orders."""
|
||||||
|
is_manager = user.role in ("manager", "sysadmin")
|
||||||
|
if is_manager:
|
||||||
|
return {"can_cancel": True, "reason": None}
|
||||||
|
can = _waiter_can_cancel(user, db)
|
||||||
|
reason = None
|
||||||
|
if not can:
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
|
||||||
|
global_on = row is not None and row.value == "true"
|
||||||
|
if not global_on:
|
||||||
|
reason = "global_disabled"
|
||||||
|
elif not user.can_cancel_orders:
|
||||||
|
reason = "user_disabled"
|
||||||
|
return {"can_cancel": can, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[OrderOut])
|
@router.get("/", response_model=List[OrderOut])
|
||||||
def list_orders(
|
def list_orders(
|
||||||
order_status: Optional[str] = None,
|
order_status: Optional[str] = None,
|
||||||
@@ -362,14 +389,41 @@ def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Sess
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def cancel_item(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
print_cancellation: bool = True,
|
||||||
|
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")
|
||||||
|
|
||||||
|
is_manager = user.role in ("manager", "sysadmin")
|
||||||
|
if not is_manager and not _waiter_can_cancel(user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
|
||||||
|
if not is_manager and not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if item.status == "paid":
|
||||||
|
raise HTTPException(status_code=400, detail="Το αντικείμενο έχει ήδη πληρωθεί")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
item.status = "cancelled"
|
item.status = "cancelled"
|
||||||
|
item.cancelled_by = user.id
|
||||||
|
item.cancelled_at = now
|
||||||
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
should_print = print_cancellation if is_manager else True
|
||||||
|
if should_print:
|
||||||
|
background_tasks.add_task(print_cancellation_ticket, order_id, [item_id])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{order_id}/pay")
|
@router.post("/{order_id}/pay")
|
||||||
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
@@ -546,14 +600,25 @@ def pay_items_offline(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def cancel_order(
|
||||||
|
order_id: int,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
print_cancellation: bool = True,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
|
||||||
|
is_manager = user.role in ("manager", "sysadmin")
|
||||||
|
if not is_manager and not _waiter_can_cancel(user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
|
||||||
|
if not is_manager and not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Cancel all still-active items
|
|
||||||
active_items = db.query(OrderItem).filter(
|
active_items = db.query(OrderItem).filter(
|
||||||
OrderItem.order_id == order_id,
|
OrderItem.order_id == order_id,
|
||||||
OrderItem.status == "active",
|
OrderItem.status == "active",
|
||||||
@@ -561,18 +626,76 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
|||||||
cancelled_item_ids = []
|
cancelled_item_ids = []
|
||||||
for item in active_items:
|
for item in active_items:
|
||||||
item.status = "cancelled"
|
item.status = "cancelled"
|
||||||
|
item.cancelled_by = user.id
|
||||||
|
item.cancelled_at = now
|
||||||
cancelled_item_ids.append(item.id)
|
cancelled_item_ids.append(item.id)
|
||||||
|
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
order.closed_at = now
|
order.closed_at = now
|
||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
|
|
||||||
note = f"Ακύρωση από manager — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
|
note = f"Ακύρωση — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
|
||||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
|
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||||
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
|
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
|
||||||
db.commit()
|
db.commit()
|
||||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||||
|
|
||||||
|
should_print = print_cancellation if is_manager else True
|
||||||
|
if should_print and cancelled_item_ids:
|
||||||
|
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_item_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class CancelItemsRequest(BaseModel):
|
||||||
|
item_ids: List[int]
|
||||||
|
print_cancellation: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/cancel-items")
|
||||||
|
def cancel_items_bulk(
|
||||||
|
order_id: int,
|
||||||
|
body: CancelItemsRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Cancel a selection of items by ID. Used by the waiter PWA multi-select cancel."""
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
|
||||||
|
is_manager = user.role in ("manager", "sysadmin")
|
||||||
|
if not is_manager and not _waiter_can_cancel(user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
|
||||||
|
if not is_manager and not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id.in_(body.item_ids),
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cancelled_ids = []
|
||||||
|
skipped_paid = []
|
||||||
|
for item in items:
|
||||||
|
if item.status == "paid":
|
||||||
|
skipped_paid.append(item.id)
|
||||||
|
continue
|
||||||
|
item.status = "cancelled"
|
||||||
|
item.cancelled_by = user.id
|
||||||
|
item.cancelled_at = now
|
||||||
|
cancelled_ids.append(item.id)
|
||||||
|
|
||||||
|
if cancelled_ids:
|
||||||
|
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
should_print = body.print_cancellation if is_manager else True
|
||||||
|
if should_print and cancelled_ids:
|
||||||
|
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids)
|
||||||
|
|
||||||
|
return {"cancelled": cancelled_ids, "skipped_paid": skipped_paid}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{order_id}/assign-waiter")
|
@router.put("/{order_id}/assign-waiter")
|
||||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -136,9 +136,36 @@ def shift_orders_summary(
|
|||||||
q = q.filter(OrderItem.added_by == waiter_id)
|
q = q.filter(OrderItem.added_by == waiter_id)
|
||||||
items = q.all()
|
items = q.all()
|
||||||
|
|
||||||
|
# Also fetch cancelled items for the same window/waiter
|
||||||
|
cq = db.query(OrderItem).filter(OrderItem.status == "cancelled")
|
||||||
|
if business_day_id:
|
||||||
|
cq = cq.join(Order, OrderItem.order_id == Order.id).filter(Order.business_day_id == business_day_id)
|
||||||
|
else:
|
||||||
|
cq = cq.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
|
||||||
|
if waiter_id:
|
||||||
|
cq = cq.filter(OrderItem.added_by == waiter_id)
|
||||||
|
cancelled_items = cq.all()
|
||||||
|
|
||||||
waiters_db = {u.id: u for u in db.query(User).all()}
|
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
|
||||||
|
# Track shifts for duration (hours worked)
|
||||||
|
shift_q = db.query(WaiterShift)
|
||||||
|
if business_day_id:
|
||||||
|
shift_q = shift_q.filter(WaiterShift.business_day_id == business_day_id)
|
||||||
|
else:
|
||||||
|
shift_q = shift_q.filter(WaiterShift.started_at >= start, WaiterShift.started_at < end)
|
||||||
|
if waiter_id:
|
||||||
|
shift_q = shift_q.filter(WaiterShift.waiter_id == waiter_id)
|
||||||
|
shifts_list = shift_q.all()
|
||||||
|
# hours worked per waiter
|
||||||
|
hours_by_waiter: dict[int, float] = {}
|
||||||
|
for s in shifts_list:
|
||||||
|
wid = s.waiter_id
|
||||||
|
end_time = s.ended_at or datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
delta = (end_time - s.started_at).total_seconds() / 3600.0
|
||||||
|
hours_by_waiter[wid] = hours_by_waiter.get(wid, 0.0) + delta
|
||||||
|
|
||||||
summary: dict[int, dict] = {}
|
summary: dict[int, dict] = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
wid = item.added_by
|
wid = item.added_by
|
||||||
@@ -150,6 +177,7 @@ def shift_orders_summary(
|
|||||||
"waiter_name": wname,
|
"waiter_name": wname,
|
||||||
"items": 0,
|
"items": 0,
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
|
"cancellations": 0,
|
||||||
"order_data": {},
|
"order_data": {},
|
||||||
}
|
}
|
||||||
summary[wid]["items"] += item.quantity
|
summary[wid]["items"] += item.quantity
|
||||||
@@ -173,10 +201,26 @@ def shift_orders_summary(
|
|||||||
{"name": product_name, "quantity": item.quantity}
|
{"name": product_name, "quantity": item.quantity}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for item in cancelled_items:
|
||||||
|
wid = item.added_by
|
||||||
|
if wid not in summary:
|
||||||
|
w = waiters_db.get(wid)
|
||||||
|
wname = (w.full_name or w.username) if w else f"#{wid}"
|
||||||
|
summary[wid] = {
|
||||||
|
"waiter_id": wid,
|
||||||
|
"waiter_name": wname,
|
||||||
|
"items": 0,
|
||||||
|
"total": 0.0,
|
||||||
|
"cancellations": 0,
|
||||||
|
"order_data": {},
|
||||||
|
}
|
||||||
|
summary[wid]["cancellations"] = summary[wid].get("cancellations", 0) + item.quantity
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for entry in summary.values():
|
for entry in summary.values():
|
||||||
entry["orders"] = len(entry["order_data"])
|
entry["orders"] = len(entry["order_data"])
|
||||||
entry["order_data"] = list(entry["order_data"].values())
|
entry["order_data"] = list(entry["order_data"].values())
|
||||||
|
entry["hours_worked"] = round(hours_by_waiter.get(entry["waiter_id"], 0.0), 2)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
||||||
@@ -1380,22 +1424,45 @@ def cancellations_log(
|
|||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
|
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
|
||||||
|
|
||||||
|
# Date filter on cancelled_at if available, otherwise fall back to added_at
|
||||||
if from_dt:
|
if from_dt:
|
||||||
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
|
dt_from = datetime.fromisoformat(from_dt)
|
||||||
|
from sqlalchemy import or_
|
||||||
|
q = q.filter(or_(
|
||||||
|
OrderItem.cancelled_at >= dt_from,
|
||||||
|
(OrderItem.cancelled_at == None) & (OrderItem.added_at >= dt_from),
|
||||||
|
))
|
||||||
if to_dt:
|
if to_dt:
|
||||||
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
|
dt_to = datetime.fromisoformat(to_dt)
|
||||||
|
from sqlalchemy import or_
|
||||||
|
q = q.filter(or_(
|
||||||
|
OrderItem.cancelled_at <= dt_to,
|
||||||
|
(OrderItem.cancelled_at == None) & (OrderItem.added_at <= dt_to),
|
||||||
|
))
|
||||||
if business_day_id:
|
if business_day_id:
|
||||||
q = q.join(Order).filter(Order.business_day_id == business_day_id)
|
q = q.join(Order, Order.id == OrderItem.order_id).filter(Order.business_day_id == business_day_id)
|
||||||
if waiter_id:
|
if waiter_id:
|
||||||
q = q.filter(OrderItem.cancelled_by == waiter_id)
|
# Show items cancelled BY this waiter OR items that were on this waiter's order
|
||||||
|
from sqlalchemy import or_
|
||||||
|
q = q.filter(or_(
|
||||||
|
OrderItem.cancelled_by == waiter_id,
|
||||||
|
OrderItem.added_by == waiter_id,
|
||||||
|
))
|
||||||
|
|
||||||
items = q.order_by(OrderItem.added_at.desc()).all()
|
items = q.order_by(OrderItem.added_at.desc()).all()
|
||||||
waiters_db = {u.id: u for u in db.query(User).all()}
|
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||||
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
orders_cache = {}
|
||||||
|
|
||||||
|
def _get_order(oid):
|
||||||
|
if oid not in orders_cache:
|
||||||
|
orders_cache[oid] = db.query(Order).filter(Order.id == oid).first()
|
||||||
|
return orders_cache[oid]
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for item in items:
|
for item in items:
|
||||||
order = db.query(Order).filter(Order.id == item.order_id).first()
|
order = _get_order(item.order_id)
|
||||||
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else "—"
|
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else "—"
|
||||||
waiter = waiters_db.get(item.added_by)
|
waiter = waiters_db.get(item.added_by)
|
||||||
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"
|
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|||||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||||
pay_data = compute_shift_pay(shift)
|
pay_data = compute_shift_pay(shift)
|
||||||
|
# Count cancelled items attributed to this waiter during shift window
|
||||||
|
cancelled_q = db.query(OrderItem).filter(
|
||||||
|
OrderItem.status == "cancelled",
|
||||||
|
OrderItem.added_by == shift.waiter_id,
|
||||||
|
OrderItem.added_at >= shift.started_at,
|
||||||
|
)
|
||||||
|
if shift.ended_at:
|
||||||
|
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
|
||||||
|
cancellations = cancelled_q.count()
|
||||||
return {
|
return {
|
||||||
"id": shift.id,
|
"id": shift.id,
|
||||||
"waiter_id": shift.waiter_id,
|
"waiter_id": shift.waiter_id,
|
||||||
@@ -90,6 +99,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|||||||
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
|
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
|
||||||
"duration_hours": pay_data["duration_hours"],
|
"duration_hours": pay_data["duration_hours"],
|
||||||
"shift_pay": pay_data["shift_pay"],
|
"shift_pay": pay_data["shift_pay"],
|
||||||
|
"cancellations": cancellations,
|
||||||
# Phase 2E
|
# Phase 2E
|
||||||
"counted_cash_end": shift.counted_cash_end,
|
"counted_cash_end": shift.counted_cash_end,
|
||||||
"cash_discrepancy": shift.cash_discrepancy,
|
"cash_discrepancy": shift.cash_discrepancy,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class UserBase(BaseModel):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
# Phase 2B — payroll
|
# Phase 2B — payroll
|
||||||
hourly_rate: Optional[float] = None
|
hourly_rate: Optional[float] = None
|
||||||
|
can_cancel_orders: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
@@ -33,6 +34,7 @@ class UserUpdate(BaseModel):
|
|||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
# Phase 2B — payroll
|
# Phase 2B — payroll
|
||||||
hourly_rate: Optional[float] = None
|
hourly_rate: Optional[float] = None
|
||||||
|
can_cancel_orders: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class WaiterZoneOut(BaseModel):
|
class WaiterZoneOut(BaseModel):
|
||||||
|
|||||||
@@ -1002,6 +1002,90 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = L
|
|||||||
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
def print_cancellation_ticket(order_id: int, item_ids: List[int]):
|
||||||
|
"""
|
||||||
|
Background task: print a ΑΚΥΡΩΣΗ ticket to the same printer zones
|
||||||
|
as the cancelled items. Connects to its own DB session.
|
||||||
|
"""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
_do_print_cancellation(order_id, item_ids, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Unexpected error in print_cancellation_ticket for order %s: %s", order_id, e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_print_cancellation(order_id: int, item_ids: List[int], db: Session):
|
||||||
|
if _is_spoof_mode(db):
|
||||||
|
logger.info("Spoof printing ON — dropping cancellation ticket for order %s", order_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
|
||||||
|
|
||||||
|
zone_map: dict[int, List[OrderItem]] = {}
|
||||||
|
for item in items:
|
||||||
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
|
if product and product.printer_zone_id:
|
||||||
|
zone_map.setdefault(product.printer_zone_id, []).append(item)
|
||||||
|
|
||||||
|
div = load_divider_style(db)
|
||||||
|
|
||||||
|
for printer_id, zone_items in zone_map.items():
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p = _get_printer(printer.ip_address, printer.port)
|
||||||
|
_print_cancel_ticket(p, order, zone_items, db, printer.line_width, div)
|
||||||
|
p.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Cancellation print failed for printer %s: %s", printer.name, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_cancel_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int, div: str):
|
||||||
|
cfg = _load_print_settings(db)
|
||||||
|
|
||||||
|
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
|
||||||
|
now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc))
|
||||||
|
|
||||||
|
def _cancel_banner():
|
||||||
|
p._raw(b'\x1b\x61\x01') # center
|
||||||
|
p._raw(b'\x1b\x21\x30') # double height+width
|
||||||
|
p._raw(b'\x1b\x45\x01') # bold on
|
||||||
|
_raw_text(p, "*** AKYPΩΣH ***\n")
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
|
_cancel_banner()
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Παραγγελια: #{order.id}\n")
|
||||||
|
_raw_text(p, f"Τραπεζι: {table_name}\n")
|
||||||
|
_raw_text(p, f"Ωρα: {now_str}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
|
name = product.name if product else f"#{item.product_id}"
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, _item_line(name, item.quantity, line_width) + "\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
_divider(p, div, line_width)
|
||||||
|
_cancel_banner()
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
|
||||||
|
|
||||||
# ── Analytical report prints (products / categories / tables) ─────────────────
|
# ── Analytical report prints (products / categories / tables) ─────────────────
|
||||||
|
|
||||||
def print_products_report(ip: str, port: int, report: dict):
|
def print_products_report(ip: str, port: int, report: dict):
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [confirmAction, setConfirmAction] = useState(null)
|
const [confirmAction, setConfirmAction] = useState(null)
|
||||||
|
const [printCancelConfirm, setPrintCancelConfirm] = useState(null) // { type: 'item'|'order', payload? }
|
||||||
const [printerId, setPrinterId] = useState('')
|
const [printerId, setPrinterId] = useState('')
|
||||||
|
|
||||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
|
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
|
||||||
@@ -219,13 +220,15 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const cancelItem = useMutation({
|
const cancelItem = useMutation({
|
||||||
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
|
mutationFn: ({ itemId, printCancellation }) =>
|
||||||
|
client.delete(`/api/orders/${orderId}/items/${itemId}?print_cancellation=${printCancellation}`),
|
||||||
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
|
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
|
||||||
onError: () => toast.error('Σφάλμα ακύρωσης'),
|
onError: () => toast.error('Σφάλμα ακύρωσης'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelOrder = useMutation({
|
const cancelOrder = useMutation({
|
||||||
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
mutationFn: ({ printCancellation }) =>
|
||||||
|
client.delete(`/api/orders/${orderId}?print_cancellation=${printCancellation}`),
|
||||||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
|
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
|
||||||
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||||||
})
|
})
|
||||||
@@ -244,10 +247,28 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
if (!confirmAction) return
|
if (!confirmAction) return
|
||||||
if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
|
const type = confirmAction.type
|
||||||
if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
|
const payload = confirmAction.payload
|
||||||
if (confirmAction.type === 'closeOrder') closeOrder.mutate()
|
|
||||||
setConfirmAction(null)
|
setConfirmAction(null)
|
||||||
|
if (type === 'cancelItem') {
|
||||||
|
setPrintCancelConfirm({ type: 'item', payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'cancelOrder') {
|
||||||
|
setPrintCancelConfirm({ type: 'order' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'closeOrder') closeOrder.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeCancelWithPrint(printCancellation) {
|
||||||
|
if (!printCancelConfirm) return
|
||||||
|
if (printCancelConfirm.type === 'item') {
|
||||||
|
cancelItem.mutate({ itemId: printCancelConfirm.payload, printCancellation })
|
||||||
|
} else {
|
||||||
|
cancelOrder.mutate({ printCancellation })
|
||||||
|
}
|
||||||
|
setPrintCancelConfirm(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = order ? orderTotal(order.items) : 0
|
const total = order ? orderTotal(order.items) : 0
|
||||||
@@ -475,6 +496,38 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
|
|||||||
onCancel={() => setConfirmAction(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{printCancelConfirm && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: 20, zIndex: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white', borderRadius: 14, padding: '24px 28px',
|
||||||
|
maxWidth: 360, width: '100%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 28, marginBottom: 12 }}>🖨️</div>
|
||||||
|
<p style={{ fontWeight: 700, fontSize: 16, margin: '0 0 6px', color: '#111315' }}>
|
||||||
|
Εκτύπωση ακύρωσης;
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: '#6b7280', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||||
|
Θέλετε να σταλεί ακυρωτικό ticket στον εκτυπωτή;
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => executeCancelWithPrint(false)}
|
||||||
|
style={{ padding: '9px 18px', borderRadius: 8, border: '1px solid #e5e7eb', background: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
|
||||||
|
>Όχι, απλή ακύρωση</button>
|
||||||
|
<button
|
||||||
|
onClick={() => executeCancelWithPrint(true)}
|
||||||
|
style={{ padding: '9px 18px', borderRadius: 8, border: 'none', background: '#dc2626', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
>Ναι, εκτύπωση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function ShiftSettingsSection() {
|
|||||||
}
|
}
|
||||||
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
|
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
|
||||||
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
|
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
|
||||||
|
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
|
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
|
||||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||||
@@ -76,6 +77,12 @@ function ShiftSettingsSection() {
|
|||||||
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
|
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
|
||||||
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
<OptionRow
|
||||||
|
label="Ακυρώσεις Παραγγελιών από Σερβιτόρους"
|
||||||
|
description="Επιτρέπει σε εξουσιοδοτημένους σερβιτόρους να ακυρώνουν παραγγελίες ή αντικείμενα. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να ακυρώσει — ανεξάρτητα από τις ατομικές ρυθμίσεις."
|
||||||
|
>
|
||||||
|
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
|
||||||
|
</OptionRow>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ const inputStyle = {
|
|||||||
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
|
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '' }
|
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '', can_cancel_orders: false }
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function WaitersPage() {
|
export default function WaitersPage() {
|
||||||
@@ -277,7 +277,7 @@ export default function WaitersPage() {
|
|||||||
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
||||||
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
||||||
const [editModal, setEditModal] = useState(null)
|
const [editModal, setEditModal] = useState(null)
|
||||||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '' })
|
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '', can_cancel_orders: false })
|
||||||
const avatarInputRef = useRef(null)
|
const avatarInputRef = useRef(null)
|
||||||
const newAvatarInputRef = useRef(null)
|
const newAvatarInputRef = useRef(null)
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
function openEdit(w) {
|
function openEdit(w) {
|
||||||
setEditModal(w)
|
setEditModal(w)
|
||||||
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' })
|
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '', can_cancel_orders: !!w.can_cancel_orders })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -585,7 +585,7 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 3: Payroll + PIN */}
|
{/* Column 3: Payroll + Permissions + PIN */}
|
||||||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
||||||
<div>
|
<div>
|
||||||
<SectionLabel>Μισθοδοσία</SectionLabel>
|
<SectionLabel>Μισθοδοσία</SectionLabel>
|
||||||
@@ -609,6 +609,23 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Δικαιώματα</SectionLabel>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.can_cancel_orders}
|
||||||
|
onChange={e => f('can_cancel_orders', e.target.checked)}
|
||||||
|
style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>Ακύρωση παραγγελιών</span>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>
|
||||||
|
Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<SectionLabel>Κωδικός PIN</SectionLabel>
|
<SectionLabel>Κωδικός PIN</SectionLabel>
|
||||||
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||||
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
|
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
|
||||||
@@ -788,6 +805,24 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Δικαιώματα</SectionLabel>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.can_cancel_orders}
|
||||||
|
onChange={e => f('can_cancel_orders', e.target.checked)}
|
||||||
|
style={{ marginTop: 2, width: 16, height: 16, accentColor: '#dc2626', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>Ακύρωση παραγγελιών</span>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>
|
||||||
|
Επιτρέπει στον σερβιτόρο να ακυρώνει αντικείμενα ή ολόκληρες παραγγελίες. Ισχύει μόνο αν είναι ενεργό και στις γενικές ρυθμίσεις.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
|
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
|
||||||
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
|
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
|
||||||
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
|
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
|||||||
<DataTable>
|
<DataTable>
|
||||||
<THead>
|
<THead>
|
||||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||||
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
|
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.slice(0, 200).map(o => {
|
{orders.slice(0, 200).map(o => {
|
||||||
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
|
||||||
|
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
|
||||||
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
const isCancelled = o.status === 'cancelled'
|
const isCancelled = o.status === 'cancelled'
|
||||||
return (
|
return (
|
||||||
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
||||||
@@ -104,7 +106,12 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
|||||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||||
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
||||||
<TD><StatusBadge status={o.status} /></TD>
|
<TD><StatusBadge status={o.status} /></TD>
|
||||||
<TD mono align="right">{(o.items || []).length}</TD>
|
<TD mono align="right">{activeItems.length}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{cancelledItems.length > 0
|
||||||
|
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
|
||||||
|
: <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||||
<TD align="right">
|
<TD align="right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -71,18 +71,25 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
|
|||||||
: <DataTable>
|
: <DataTable>
|
||||||
<THead>
|
<THead>
|
||||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||||
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
<TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map(o => {
|
{orders.map(o => {
|
||||||
const total = (o.items || []).filter(i => i.status !== 'cancelled').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
const activeItems = (o.items || []).filter(i => i.status !== 'cancelled')
|
||||||
|
const cancelledItems = (o.items || []).filter(i => i.status === 'cancelled')
|
||||||
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
return (
|
return (
|
||||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
|
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
|
||||||
<TD mono>#{o.id}</TD>
|
<TD mono>#{o.id}</TD>
|
||||||
<TD>{o.table_name ?? o.table_id}</TD>
|
<TD>{o.table_name ?? o.table_id}</TD>
|
||||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||||
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
|
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
|
||||||
<TD mono align="right">{(o.items || []).length}</TD>
|
<TD mono align="right">{activeItems.length}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{cancelledItems.length > 0
|
||||||
|
? <span className="text-red-500 font-semibold">{cancelledItems.length}</span>
|
||||||
|
: <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||||
<TD><StatusBadge status={o.status} /></TD>
|
<TD><StatusBadge status={o.status} /></TD>
|
||||||
</TR>
|
</TR>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, Legend, ComposedChart, Line,
|
||||||
|
} from 'recharts'
|
||||||
import client from '../../../api/client'
|
import client from '../../../api/client'
|
||||||
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
|
||||||
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import SkeletonTable from '../shared/SkeletonTable'
|
import SkeletonTable from '../shared/SkeletonTable'
|
||||||
import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
||||||
@@ -11,12 +14,53 @@ import { fmtEUR, fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
|
|||||||
function today() { return new Date().toISOString().slice(0, 10) }
|
function today() { return new Date().toISOString().slice(0, 10) }
|
||||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
const METRICS = [
|
||||||
|
{ key: 'orders', label: 'ΠΑΡΑΓΓΕΛΙΕΣ', color: '#60a5fa' },
|
||||||
|
{ key: 'items', label: 'ΕΙΔΗ', color: '#34d399' },
|
||||||
|
{ key: 'cancellations', label: 'ΑΚΥΡΩΣΕΙΣ', color: '#f87171' },
|
||||||
|
{ key: 'total', label: 'ΕΣΟΔΑ', color: '#a78bfa' },
|
||||||
|
{ key: 'hours_worked', label: 'ΩΡΕΣ', color: '#fb923c', rightAxis: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function fmtHours(h) {
|
||||||
|
if (h == null) return '—'
|
||||||
|
const hrs = Math.floor(h)
|
||||||
|
const mins = Math.round((h - hrs) * 60)
|
||||||
|
if (mins === 0) return `${hrs}ω`
|
||||||
|
return `${hrs}ω ${mins}λ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({ active, payload, label }) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8,
|
||||||
|
padding: '10px 14px', fontSize: 12, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||||
|
minWidth: 160,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#1e293b', marginBottom: 6 }}>{label}</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ display: 'flex', justifyContent: 'space-between', gap: 16, color: '#475569', marginBottom: 2 }}>
|
||||||
|
<span style={{ color: p.color, fontWeight: 600 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#1e293b' }}>
|
||||||
|
{p.dataKey === 'total' ? fmtEUR(p.value)
|
||||||
|
: p.dataKey === 'hours_worked' ? fmtHours(p.value)
|
||||||
|
: fmtNum(p.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Activity() {
|
export default function Activity() {
|
||||||
const [waiterId, setWaiterId] = useState('all')
|
const [waiterId, setWaiterId] = useState('all')
|
||||||
const [mode, setMode] = useState('range')
|
const [mode, setMode] = useState('range')
|
||||||
const [from, setFrom] = useState(monthAgo())
|
const [from, setFrom] = useState(monthAgo())
|
||||||
const [to, setTo] = useState(today())
|
const [to, setTo] = useState(today())
|
||||||
const [businessDayId, setBusinessDayId] = useState('all')
|
const [businessDayId, setBusinessDayId] = useState('all')
|
||||||
|
const [activeMetrics, setActiveMetrics] = useState(new Set(['orders', 'items', 'cancellations', 'total']))
|
||||||
|
const [stacked, setStacked] = useState(false)
|
||||||
|
|
||||||
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||||
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
|
||||||
@@ -39,10 +83,28 @@ export default function Activity() {
|
|||||||
const waiters = data?.waiters || []
|
const waiters = data?.waiters || []
|
||||||
|
|
||||||
const chartData = useMemo(() => waiters.map(w => ({
|
const chartData = useMemo(() => waiters.map(w => ({
|
||||||
name: (w.waiter_name || '').split(' ')[0],
|
name: w.waiter_name || `#${w.waiter_id}`,
|
||||||
orders: w.orders,
|
shortName: (w.waiter_name || '').split(' ')[0] || `#${w.waiter_id}`,
|
||||||
|
orders: w.orders || 0,
|
||||||
|
items: w.items || 0,
|
||||||
|
cancellations: w.cancellations || 0,
|
||||||
|
total: Math.round((w.total || 0) * 100) / 100,
|
||||||
|
hours_worked: w.hours_worked || 0,
|
||||||
})), [waiters])
|
})), [waiters])
|
||||||
|
|
||||||
|
function toggleMetric(key) {
|
||||||
|
setActiveMetrics(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) { if (next.size > 1) next.delete(key) }
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRightAxis = activeMetrics.has('hours_worked')
|
||||||
|
const leftMetrics = METRICS.filter(m => !m.rightAxis && activeMetrics.has(m.key))
|
||||||
|
const hasRevenue = activeMetrics.has('total')
|
||||||
|
|
||||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={5} columns={7} showChart /></div>
|
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={5} columns={7} showChart /></div>
|
||||||
if (isError) return (
|
if (isError) return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
@@ -68,16 +130,106 @@ export default function Activity() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{waiters.length > 0 && (
|
{waiters.length > 0 && (
|
||||||
<Panel title="Παραγγελίες ανά Σερβιτόρο" subtitle="Συνολικές παραγγελίες στην επιλεγμένη περίοδο">
|
<Panel
|
||||||
<div style={{ height: 240 }}>
|
title="Δραστηριότητα ανά Σερβιτόρο"
|
||||||
|
subtitle="Επιλέξτε μετρικές και τύπο γραφήματος"
|
||||||
|
right={
|
||||||
|
<div className="flex items-center gap-1 rounded-md border border-slate-200 p-0.5 bg-slate-50">
|
||||||
|
<button
|
||||||
|
onClick={() => setStacked(false)}
|
||||||
|
className={`rounded px-2.5 py-1 text-[11px] font-semibold transition-colors ${!stacked ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Ομαδοποίηση
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStacked(true)}
|
||||||
|
className={`rounded px-2.5 py-1 text-[11px] font-semibold transition-colors ${stacked ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Στοίβαγμα
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Metric checkboxes */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{METRICS.map(m => {
|
||||||
|
const active = activeMetrics.has(m.key)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => toggleMetric(m.key)}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider transition-all"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? m.color : '#e2e8f0',
|
||||||
|
background: active ? m.color + '18' : 'transparent',
|
||||||
|
color: active ? m.color : '#94a3b8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: active ? m.color : '#cbd5e1', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 260 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
|
<ComposedChart data={chartData} layout="vertical" margin={{ top: 4, right: showRightAxis ? 60 : 24, bottom: 4, left: 8 }}>
|
||||||
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
|
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
|
||||||
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
|
<XAxis
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={80} />
|
type="number"
|
||||||
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} />
|
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||||
<Bar dataKey="orders" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={18} />
|
stroke="#cbd5e1" axisLine={false} tickLine={false}
|
||||||
</BarChart>
|
tickFormatter={v => hasRevenue && leftMetrics.length === 1 ? `€${v}` : v}
|
||||||
|
/>
|
||||||
|
{showRightAxis && (
|
||||||
|
<XAxis
|
||||||
|
xAxisId="right"
|
||||||
|
type="number"
|
||||||
|
orientation="top"
|
||||||
|
tick={{ fontSize: 11, fill: '#fb923c' }}
|
||||||
|
stroke="#fed7aa" axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={v => `${v}ω`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 12, fill: '#475569' }}
|
||||||
|
stroke="#cbd5e1" axisLine={false} tickLine={false}
|
||||||
|
width={90}
|
||||||
|
tickFormatter={v => v.length > 12 ? v.slice(0, 11) + '…' : v}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#f1f5f9' }} />
|
||||||
|
{METRICS.filter(m => !m.rightAxis && activeMetrics.has(m.key)).map(m => (
|
||||||
|
<Bar
|
||||||
|
key={m.key}
|
||||||
|
dataKey={m.key}
|
||||||
|
name={m.label}
|
||||||
|
fill={m.color}
|
||||||
|
radius={stacked ? 0 : [0, 3, 3, 0]}
|
||||||
|
barSize={stacked ? 20 : 10}
|
||||||
|
stackId={stacked ? 'stack' : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeMetrics.has('hours_worked') && (
|
||||||
|
<Bar
|
||||||
|
key="hours_worked"
|
||||||
|
xAxisId="right"
|
||||||
|
dataKey="hours_worked"
|
||||||
|
name="ΩΡΕΣ"
|
||||||
|
fill="#fb923c"
|
||||||
|
radius={[0, 3, 3, 0]}
|
||||||
|
barSize={6}
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -93,14 +245,22 @@ export default function Activity() {
|
|||||||
<TH>Σερβιτόρος</TH>
|
<TH>Σερβιτόρος</TH>
|
||||||
<TH align="right">Παραγγελίες</TH>
|
<TH align="right">Παραγγελίες</TH>
|
||||||
<TH align="right">Είδη</TH>
|
<TH align="right">Είδη</TH>
|
||||||
|
<TH align="right">Ακυρώσεις</TH>
|
||||||
|
<TH align="right">Ώρες</TH>
|
||||||
<TH align="right">Συνολική Αξία</TH>
|
<TH align="right">Συνολική Αξία</TH>
|
||||||
</THead>
|
</THead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{waiters.sort((a, b) => b.total - a.total).map(w => (
|
{[...waiters].sort((a, b) => (b.total || 0) - (a.total || 0)).map(w => (
|
||||||
<TR key={w.waiter_id} striped>
|
<TR key={w.waiter_id} striped>
|
||||||
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
|
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
|
||||||
<TD mono align="right">{fmtNum(w.orders)}</TD>
|
<TD mono align="right">{fmtNum(w.orders)}</TD>
|
||||||
<TD mono align="right">{fmtNum(w.items)}</TD>
|
<TD mono align="right">{fmtNum(w.items)}</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{w.cancellations > 0
|
||||||
|
? <span className="text-red-500 font-semibold">{fmtNum(w.cancellations)}</span>
|
||||||
|
: <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
|
<TD mono align="right">{fmtHours(w.hours_worked)}</TD>
|
||||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(w.total)}</TD>
|
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(w.total)}</TD>
|
||||||
</TR>
|
</TR>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
<TH align="right">Εισπράχθηκαν</TH>
|
<TH align="right">Εισπράχθηκαν</TH>
|
||||||
<TH align="right">Οφείλει</TH>
|
<TH align="right">Οφείλει</TH>
|
||||||
<TH align="right">Ταμείο</TH>
|
<TH align="right">Ταμείο</TH>
|
||||||
|
<TH align="right">Ακυρώσεις</TH>
|
||||||
<TH>Κατάσταση</TH>
|
<TH>Κατάσταση</TH>
|
||||||
<TH className="w-28" />
|
<TH className="w-28" />
|
||||||
</THead>
|
</THead>
|
||||||
@@ -177,6 +178,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
<span className="text-slate-400 text-[11px]">—</span>
|
<span className="text-slate-400 text-[11px]">—</span>
|
||||||
)}
|
)}
|
||||||
</TD>
|
</TD>
|
||||||
|
<TD mono align="right">
|
||||||
|
{s.cancellations > 0
|
||||||
|
? <span className="text-red-600 font-semibold">{s.cancellations}</span>
|
||||||
|
: <span className="text-slate-300">—</span>}
|
||||||
|
</TD>
|
||||||
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||||
<TD align="right">
|
<TD align="right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -201,7 +207,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
|||||||
</TR>,
|
</TR>,
|
||||||
isOpen && (
|
isOpen && (
|
||||||
<tr key={`${s.id}-detail`}>
|
<tr key={`${s.id}-detail`}>
|
||||||
<td colSpan={12} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
<td colSpan={13} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
||||||
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
||||||
<span>
|
<span>
|
||||||
{'Εργάσιμη Μέρα: '}
|
{'Εργάσιμη Μέρα: '}
|
||||||
|
|||||||
@@ -83,69 +83,104 @@ function SplitModal({ item, onConfirm, onClose }) {
|
|||||||
|
|
||||||
// ─── Item action modal (long-press) ──────────────────────────────────────────
|
// ─── Item action modal (long-press) ──────────────────────────────────────────
|
||||||
|
|
||||||
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
|
function ItemActionModal({ target, onOrderAgain, onSplit, onMoveToTable, onCancel, canCancel, onClose }) {
|
||||||
const { items, singleStacked, multiSelect } = target
|
const { items, singleStacked, multiSelect } = target
|
||||||
const label = multiSelect
|
const label = multiSelect
|
||||||
? `${items.length} αντικείμενα επιλεγμένα`
|
? `${items.length} αντικείμενα επιλεγμένα`
|
||||||
: items[0]?.product?.name || `#${items[0]?.product_id}`
|
: items[0]?.product?.name || `#${items[0]?.product_id}`
|
||||||
|
|
||||||
|
const actionRows = [
|
||||||
|
{
|
||||||
|
key: 'order_again',
|
||||||
|
color: '#22c55e',
|
||||||
|
iconBg: 'rgba(34,197,94,0.15)',
|
||||||
|
label: 'Παραγγελία ξανά',
|
||||||
|
sub: 'Προσθήκη στο νέο καλάθι',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#22c55e" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: onOrderAgain,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'move',
|
||||||
|
color: '#f97316',
|
||||||
|
iconBg: 'rgba(249,115,22,0.15)',
|
||||||
|
label: 'Μεταφορά σε άλλο τραπέζι',
|
||||||
|
sub: 'Μεταφορά επιλεγμένων αντικειμένων',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: onMoveToTable,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
...(singleStacked && !multiSelect ? [{
|
||||||
|
key: 'split',
|
||||||
|
color: '#60a5fa',
|
||||||
|
iconBg: 'rgba(96,165,250,0.15)',
|
||||||
|
label: 'Διαχωρισμός',
|
||||||
|
sub: 'Χώρισμα σε δύο γραμμές',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: onSplit,
|
||||||
|
enabled: true,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
color: canCancel ? '#ef4444' : '#6b7280',
|
||||||
|
iconBg: canCancel ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)',
|
||||||
|
label: 'Ακύρωση παραγγελίας',
|
||||||
|
sub: canCancel ? 'Ακύρωση επιλεγμένων αντικειμένων' : 'Δεν επιτρέπεται η ακύρωση',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2"/>
|
||||||
|
<path d="M15 9l-6 6M9 9l6 6" stroke={canCancel ? '#ef4444' : '#6b7280'} strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: canCancel ? onCancel : null,
|
||||||
|
enabled: canCancel,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
|
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
|
||||||
<div className="modal-handle" />
|
<div className="modal-handle" />
|
||||||
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
|
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
|
||||||
|
|
||||||
|
{actionRows.map((a, i) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOrderAgain}
|
key={a.key}
|
||||||
|
onClick={a.onClick ? a.onClick : undefined}
|
||||||
|
disabled={!a.enabled}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
||||||
padding: '16px 4px', background: 'none', border: 'none',
|
padding: '16px 4px', background: 'none', border: 'none',
|
||||||
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
|
borderBottom: i < actionRows.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
cursor: 'pointer', textAlign: 'left',
|
cursor: a.enabled ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: a.enabled ? 1 : 0.45, textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||||
background: 'rgba(245,158,11,0.15)',
|
background: a.iconBg,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
}}>{a.icon}</span>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
|
<div style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{a.sub}</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
{a.enabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
{singleStacked && !multiSelect && (
|
|
||||||
<button
|
|
||||||
onClick={onSplit}
|
|
||||||
style={{
|
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
|
||||||
padding: '16px 4px', background: 'none', border: 'none',
|
|
||||||
cursor: 'pointer', textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
|
||||||
background: 'rgba(96,165,250,0.15)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
|
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
|
||||||
Άκυρο
|
Άκυρο
|
||||||
@@ -157,7 +192,16 @@ function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
|
|||||||
|
|
||||||
// ─── Actions top sheet ────────────────────────────────────────────────────────
|
// ─── Actions top sheet ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
|
function CancelIcon({ width = 20, height = 20 }) {
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis, onCancelOrder, canCancel }) {
|
||||||
const hasOrder = !!order
|
const hasOrder = !!order
|
||||||
const actions = [
|
const actions = [
|
||||||
{ Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
|
{ Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
|
||||||
@@ -165,6 +209,8 @@ function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags
|
|||||||
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
||||||
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
|
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
|
||||||
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
|
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
|
||||||
|
{ Icon: CancelIcon, label: 'Ακύρωση Παραγγελίας', sub: canCancel ? 'Ακύρωση ολόκληρης παραγγελίας' : 'Δεν επιτρέπεται η ακύρωση',
|
||||||
|
onClick: (hasOrder && canCancel) ? onCancelOrder : null, color: (hasOrder && canCancel) ? '#ef4444' : '#6b7280', iconBg: (hasOrder && canCancel) ? 'rgba(239,68,68,0.15)' : 'rgba(107,114,128,0.1)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -437,6 +483,32 @@ function PrintSynopsisPicker({ orderId, onClose }) {
|
|||||||
|
|
||||||
// ─── Pay confirm modal ────────────────────────────────────────────────────────
|
// ─── Pay confirm modal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CancelConfirmModal({ itemCount, onConfirm, onClose }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-sheet" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-handle" />
|
||||||
|
<h2 className="modal-title">Ακύρωση παραγγελίας;</h2>
|
||||||
|
<p style={{ color: '#94a3b8', textAlign: 'center', marginBottom: 24, fontSize: 14 }}>
|
||||||
|
{itemCount === null
|
||||||
|
? 'Ολόκληρη η παραγγελία θα ακυρωθεί οριστικά.'
|
||||||
|
: `${itemCount} αντικείμενο${itemCount !== 1 ? 'α' : ''} θα ακυρωθ${itemCount !== 1 ? 'ούν' : 'εί'} οριστικά.`}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Άκυρο</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
flex: 1, height: 48, borderRadius: 12, border: 'none',
|
||||||
|
background: '#dc2626', color: 'white', fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>Ακύρωση ✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
|
function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
|
||||||
const payTotal = activeItems
|
const payTotal = activeItems
|
||||||
.filter(i => payIds.includes(i.id))
|
.filter(i => payIds.includes(i.id))
|
||||||
@@ -504,6 +576,8 @@ export default function TableDetailPage() {
|
|||||||
const [actionDataLoading, setActionDataLoading] = useState(false)
|
const [actionDataLoading, setActionDataLoading] = useState(false)
|
||||||
const [splitItem, setSplitItem] = useState(null)
|
const [splitItem, setSplitItem] = useState(null)
|
||||||
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
|
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
|
||||||
|
const [canCancel, setCanCancel] = useState(false)
|
||||||
|
const [cancelConfirm, setCancelConfirm] = useState(null) // null | { mode: 'items', ids: [...] } | { mode: 'order' }
|
||||||
|
|
||||||
const scrollRef = useRef(null)
|
const scrollRef = useRef(null)
|
||||||
|
|
||||||
@@ -533,6 +607,12 @@ export default function TableDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [tableId])
|
useEffect(() => { load() }, [tableId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.get('/api/orders/cancel-permissions')
|
||||||
|
.then(r => setCanCancel(!!r.data.can_cancel))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Handle ?action= param from table list long-press quick actions
|
// Handle ?action= param from table list long-press quick actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const action = searchParams.get('action')
|
const action = searchParams.get('action')
|
||||||
@@ -624,6 +704,28 @@ export default function TableDetailPage() {
|
|||||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeCancelItems(itemIds) {
|
||||||
|
setCancelConfirm(null)
|
||||||
|
try {
|
||||||
|
await client.post(`/api/orders/${order.id}/cancel-items`, { item_ids: itemIds, print_cancellation: true })
|
||||||
|
setSelectedIds([])
|
||||||
|
setItemActionTarget(null)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCancelOrder() {
|
||||||
|
setCancelConfirm(null)
|
||||||
|
try {
|
||||||
|
await client.delete(`/api/orders/${order.id}?print_cancellation=true`)
|
||||||
|
navigate('/tables')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Σφάλμα ακύρωσης παραγγελίας')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectAll() {
|
function selectAll() {
|
||||||
const allActive = activeItems.map(i => i.id)
|
const allActive = activeItems.map(i => i.id)
|
||||||
const allSelected = allActive.every(id => selectedIds.includes(id))
|
const allSelected = allActive.every(id => selectedIds.includes(id))
|
||||||
@@ -892,7 +994,7 @@ export default function TableDetailPage() {
|
|||||||
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
|
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
|
||||||
padding: '10px 12px 24px',
|
padding: '10px 12px 24px',
|
||||||
}}>
|
}}>
|
||||||
{/* Clear selection */}
|
{/* CLEAR */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedIds([])}
|
onClick={() => setSelectedIds([])}
|
||||||
style={{
|
style={{
|
||||||
@@ -901,13 +1003,11 @@ export default function TableDetailPage() {
|
|||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
|
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
fontSize: 13, fontWeight: 600,
|
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>CLEAR</button>
|
||||||
καθ. επιλ.
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Select all — hidden once everything is already selected */}
|
{/* ALL */}
|
||||||
{!allActiveSelected && (
|
{!allActiveSelected && (
|
||||||
<button
|
<button
|
||||||
onClick={selectAll}
|
onClick={selectAll}
|
||||||
@@ -917,10 +1017,10 @@ export default function TableDetailPage() {
|
|||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: 'rgba(34,197,94,0.18)', color: '#86efac',
|
background: 'rgba(34,197,94,0.18)', color: '#86efac',
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
fontSize: 13, fontWeight: 600,
|
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
όλα
|
ALL
|
||||||
<span style={{
|
<span style={{
|
||||||
background: 'rgba(34,197,94,0.35)', color: '#86efac',
|
background: 'rgba(34,197,94,0.35)', color: '#86efac',
|
||||||
borderRadius: 999, fontSize: 11, fontWeight: 700,
|
borderRadius: 999, fontSize: 11, fontWeight: 700,
|
||||||
@@ -929,26 +1029,25 @@ export default function TableDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transfer items */}
|
{/* ACTIONS — opens item action modal */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAllTables([])
|
const items = activeItems.filter(i => selectedIds.includes(i.id))
|
||||||
setAllOrders([])
|
const singleStacked = items.length === 1 && items[0].quantity > 1
|
||||||
loadActionData()
|
setItemActionTarget({ items, singleStacked, multiSelect: items.length > 1 })
|
||||||
setActionsMode('move_items')
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
height: 36, padding: '0 16px',
|
height: 36, padding: '0 16px',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
|
background: 'rgba(148,163,184,0.18)', color: '#cbd5e1',
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
fontSize: 13, fontWeight: 600,
|
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
μεταφορά
|
ACTIONS
|
||||||
<span style={{
|
<span style={{
|
||||||
background: 'rgba(96,165,250,0.35)', color: '#93c5fd',
|
background: 'rgba(148,163,184,0.25)', color: '#cbd5e1',
|
||||||
borderRadius: 999, fontSize: 11, fontWeight: 700,
|
borderRadius: 999, fontSize: 11, fontWeight: 700,
|
||||||
padding: '1px 6px',
|
padding: '1px 6px',
|
||||||
}}>{selectedIds.length}</span>
|
}}>{selectedIds.length}</span>
|
||||||
@@ -1020,10 +1119,11 @@ export default function TableDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Item action modal (long-press) */}
|
{/* Item action modal (long-press or ACTIONS pill) */}
|
||||||
{itemActionTarget && (
|
{itemActionTarget && (
|
||||||
<ItemActionModal
|
<ItemActionModal
|
||||||
target={itemActionTarget}
|
target={itemActionTarget}
|
||||||
|
canCancel={canCancel}
|
||||||
onOrderAgain={() => {
|
onOrderAgain={() => {
|
||||||
const items = itemActionTarget.items
|
const items = itemActionTarget.items
|
||||||
sessionStorage.setItem('orderAgainItems', JSON.stringify(
|
sessionStorage.setItem('orderAgainItems', JSON.stringify(
|
||||||
@@ -1042,10 +1142,34 @@ export default function TableDetailPage() {
|
|||||||
setSplitItem(itemActionTarget.items[0])
|
setSplitItem(itemActionTarget.items[0])
|
||||||
setItemActionTarget(null)
|
setItemActionTarget(null)
|
||||||
}}
|
}}
|
||||||
|
onMoveToTable={() => {
|
||||||
|
setItemActionTarget(null)
|
||||||
|
setAllTables([])
|
||||||
|
setAllOrders([])
|
||||||
|
loadActionData()
|
||||||
|
setActionsMode('move_items')
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
const ids = itemActionTarget.items.map(i => i.id)
|
||||||
|
setItemActionTarget(null)
|
||||||
|
setCancelConfirm({ mode: 'items', ids })
|
||||||
|
}}
|
||||||
onClose={() => setItemActionTarget(null)}
|
onClose={() => setItemActionTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cancel confirmation */}
|
||||||
|
{cancelConfirm && (
|
||||||
|
<CancelConfirmModal
|
||||||
|
itemCount={cancelConfirm.mode === 'items' ? cancelConfirm.ids.length : null}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (cancelConfirm.mode === 'items') executeCancelItems(cancelConfirm.ids)
|
||||||
|
else executeCancelOrder()
|
||||||
|
}}
|
||||||
|
onClose={() => setCancelConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Split stepper modal */}
|
{/* Split stepper modal */}
|
||||||
{splitItem && (
|
{splitItem && (
|
||||||
<SplitModal
|
<SplitModal
|
||||||
@@ -1097,12 +1221,14 @@ export default function TableDetailPage() {
|
|||||||
<ActionsSheet
|
<ActionsSheet
|
||||||
order={order}
|
order={order}
|
||||||
tableId={tableId}
|
tableId={tableId}
|
||||||
|
canCancel={canCancel}
|
||||||
onClose={() => setShowActions(false)}
|
onClose={() => setShowActions(false)}
|
||||||
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
|
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
|
||||||
onMerge={() => { setShowActions(false); setActionsMode('merge') }}
|
onMerge={() => { setShowActions(false); setActionsMode('merge') }}
|
||||||
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
|
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
|
||||||
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
|
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
|
||||||
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
|
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
|
||||||
|
onCancelOrder={() => { setShowActions(false); setCancelConfirm({ mode: 'order' }) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const QUICK_ACTIONS = [
|
|||||||
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
|
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) {
|
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction, onCancelOrder, canCancel }) {
|
||||||
const tableName = table.label || `T${table.number}`
|
const tableName = table.label || `T${table.number}`
|
||||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||||
@@ -147,7 +147,7 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 14,
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
padding: '12px 0', background: 'none', border: 'none',
|
padding: '12px 0', background: 'none', border: 'none',
|
||||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
borderBottom: '1px solid var(--border)',
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
||||||
}}>
|
}}>
|
||||||
@@ -159,6 +159,30 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{/* Cancel order */}
|
||||||
|
{(() => {
|
||||||
|
const hasActiveItems = activeItems.length > 0
|
||||||
|
const disabled = !order || !hasActiveItems
|
||||||
|
const colour = canCancel && !disabled ? '#ef4444' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={!canCancel || disabled}
|
||||||
|
onClick={() => { if (canCancel && !disabled) onCancelOrder() }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '12px 0', background: 'none', border: 'none',
|
||||||
|
cursor: canCancel && !disabled ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: canCancel && !disabled ? 1 : 0.35, textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: 'rgba(239,68,68,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: colour }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 600, color: colour }}>Ακύρωση Παραγγελίας</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,6 +382,8 @@ export default function TableListPage() {
|
|||||||
const [quickModal, setQuickModal] = useState(null)
|
const [quickModal, setQuickModal] = useState(null)
|
||||||
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
|
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
|
||||||
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
|
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
|
||||||
|
const [canCancel, setCanCancel] = useState(false)
|
||||||
|
const [cancelConfirm, setCancelConfirm] = useState(null)
|
||||||
|
|
||||||
// pull-to-refresh state
|
// pull-to-refresh state
|
||||||
const [pulling, setPulling] = useState(false)
|
const [pulling, setPulling] = useState(false)
|
||||||
@@ -433,6 +459,19 @@ export default function TableListPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.get('/api/orders/cancel-permissions').then(r => setCanCancel(r.data?.can_cancel === true)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function executeCancelOrder(orderId) {
|
||||||
|
try {
|
||||||
|
await client.delete(`/api/orders/${orderId}?print_cancellation=true`)
|
||||||
|
setCancelConfirm(null)
|
||||||
|
setQuickModal(null)
|
||||||
|
load()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── SSE live updates ───────────────────────────────────────────────────────
|
// ── SSE live updates ───────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEmergency) return
|
if (isEmergency) return
|
||||||
@@ -736,12 +775,28 @@ export default function TableListPage() {
|
|||||||
table={quickModal.table}
|
table={quickModal.table}
|
||||||
order={quickModal.order}
|
order={quickModal.order}
|
||||||
flags={quickModal.flags}
|
flags={quickModal.flags}
|
||||||
|
canCancel={canCancel}
|
||||||
onClose={() => setQuickModal(null)}
|
onClose={() => setQuickModal(null)}
|
||||||
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
|
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
|
||||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||||
|
onCancelOrder={() => setCancelConfirm({ orderId: quickModal.order?.id })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{cancelConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setCancelConfirm(null)}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 400, margin: '0 auto', background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '24px 20px 32px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-handle" style={{ marginBottom: 16 }} />
|
||||||
|
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', marginBottom: 8 }}>Ακύρωση Παραγγελίας;</p>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--muted)', marginBottom: 20 }}>Όλα τα ενεργά αντικείμενα θα ακυρωθούν και θα εκτυπωθεί απόδειξη ακύρωσης.</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={() => setCancelConfirm(null)}>Ακύρωση</button>
|
||||||
|
<button className="btn" style={{ flex: 1, background: '#ef4444', color: '#fff' }} onClick={() => executeCancelOrder(cancelConfirm.orderId)}>Επιβεβαίωση</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{emergencyPayModal && (
|
{emergencyPayModal && (
|
||||||
<EmergencyPayModal
|
<EmergencyPayModal
|
||||||
table={emergencyPayModal.table}
|
table={emergencyPayModal.table}
|
||||||
|
|||||||
Reference in New Issue
Block a user