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:
2026-06-09 12:47:02 +03:00
parent b5b647422a
commit 8a5a6f8be9
16 changed files with 855 additions and 108 deletions

View File

@@ -408,6 +408,9 @@ def _run_migrations():
)""",
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
"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:
try:

View File

@@ -26,6 +26,8 @@ class User(Base):
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Phase 2B — payroll
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_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")

View File

@@ -42,12 +42,20 @@ class MoveItemsRequest(BaseModel):
target_order_id: int
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
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:
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
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"]
@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])
def list_orders(
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)
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()
if not item:
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.cancelled_by = user.id
item.cancelled_at = now
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
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")
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)
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()
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")
now = datetime.now(timezone.utc)
# Cancel all still-active items
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
@@ -561,18 +626,76 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_item_ids.append(item.id)
order.status = "cancelled"
order.closed_at = now
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,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
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")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):

View File

@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Optional, List
from database import get_db
@@ -136,9 +136,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id)
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()}
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] = {}
for item in items:
wid = item.added_by
@@ -150,6 +177,7 @@ def shift_orders_summary(
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
@@ -173,10 +201,26 @@ def shift_orders_summary(
{"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 = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
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)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -1380,22 +1424,45 @@ def cancellations_log(
user: User = Depends(require_manager),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
# Date filter on cancelled_at if available, otherwise fall back to added_at
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:
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:
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:
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()
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()}
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 = []
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 ""
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"

View File

@@ -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}"
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)
# 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 {
"id": shift.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,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
"cancellations": cancellations,
# Phase 2E
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,

View File

@@ -16,6 +16,7 @@ class UserBase(BaseModel):
email: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
can_cancel_orders: bool = False
class UserCreate(UserBase):
@@ -33,6 +34,7 @@ class UserUpdate(BaseModel):
note: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
can_cancel_orders: Optional[bool] = None
class WaiterZoneOut(BaseModel):

View File

@@ -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)
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) ─────────────────
def print_products_report(ip: str, port: int, report: dict):

View File

@@ -203,6 +203,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
})
const [confirmAction, setConfirmAction] = useState(null)
const [printCancelConfirm, setPrintCancelConfirm] = useState(null) // { type: 'item'|'order', payload? }
const [printerId, setPrinterId] = useState('')
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({
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() },
onError: () => toast.error('Σφάλμα ακύρωσης'),
})
const cancelOrder = useMutation({
mutationFn: () => client.delete(`/api/orders/${orderId}`),
mutationFn: ({ printCancellation }) =>
client.delete(`/api/orders/${orderId}?print_cancellation=${printCancellation}`),
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
})
@@ -244,10 +247,28 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
function handleConfirm() {
if (!confirmAction) return
if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
if (confirmAction.type === 'closeOrder') closeOrder.mutate()
const type = confirmAction.type
const payload = confirmAction.payload
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
@@ -475,6 +496,38 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
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>
)
}

View File

@@ -65,6 +65,7 @@ function ShiftSettingsSection() {
}
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
@@ -76,6 +77,12 @@ function ShiftSettingsSection() {
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας" description="Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους">
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Ακυρώσεις Παραγγελιών από Σερβιτόρους"
description="Επιτρέπει σε εξουσιοδοτημένους σερβιτόρους να ακυρώνουν παραγγελίες ή αντικείμενα. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να ακυρώσει — ανεξάρτητα από τις ατομικές ρυθμίσεις."
>
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
</OptionRow>
</>
)}
</SectionCard>

View File

@@ -263,7 +263,7 @@ const inputStyle = {
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 ─────────────────────────────────────────────────────────────────
export default function WaitersPage() {
@@ -277,7 +277,7 @@ export default function WaitersPage() {
const [newAvatarFile, setNewAvatarFile] = useState(null)
const [newAvatarPreview, setNewAvatarPreview] = 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 newAvatarInputRef = useRef(null)
@@ -353,7 +353,7 @@ export default function WaitersPage() {
function openEdit(w) {
setEditModal(w)
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' })
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 (
@@ -585,7 +585,7 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
</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>
<SectionLabel>Μισθοδοσία</SectionLabel>
@@ -609,6 +609,23 @@ function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFil
</div>
</FormField>
</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>
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
@@ -788,6 +805,24 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
</FormField>
</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 }}>
<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 }}>

View File

@@ -91,11 +91,13 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
<DataTable>
<THead>
<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>
<tbody>
{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'
return (
<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.closed_at)}</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 align="right">
<button

View File

@@ -71,18 +71,25 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
: <DataTable>
<THead>
<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>
<tbody>
{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 (
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
<TD mono>#{o.id}</TD>
<TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
<TD mono>{o.closed_at ? fmtDateTime(o.closed_at) : '—'}</TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right">{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><StatusBadge status={o.status} /></TD>
</TR>

View File

@@ -1,9 +1,12 @@
import { useState, useMemo } from 'react'
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 { 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 SkeletonTable from '../shared/SkeletonTable'
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 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() {
const [waiterId, setWaiterId] = useState('all')
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
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: 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 chartData = useMemo(() => waiters.map(w => ({
name: (w.waiter_name || '').split(' ')[0],
orders: w.orders,
name: w.waiter_name || `#${w.waiter_id}`,
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])
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 (isError) return (
<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">
{waiters.length > 0 && (
<Panel title="Παραγγελίες ανά Σερβιτόρο" subtitle="Συνολικές παραγγελίες στην επιλεγμένη περίοδο">
<div style={{ height: 240 }}>
<Panel
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%">
<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" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={80} />
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="orders" fill="#60a5fa" radius={[0, 3, 3, 0]} barSize={18} />
</BarChart>
<XAxis
type="number"
tick={{ fontSize: 11, fill: '#94a3b8' }}
stroke="#cbd5e1" axisLine={false} tickLine={false}
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>
</div>
</Panel>
@@ -93,14 +245,22 @@ export default function Activity() {
<TH>Σερβιτόρος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Είδη</TH>
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Ώρες</TH>
<TH align="right">Συνολική Αξία</TH>
</THead>
<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>
<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.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>
</TR>
))}

View File

@@ -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>Κατάσταση</TH>
<TH className="w-28" />
</THead>
@@ -177,6 +178,11 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<span className="text-slate-400 text-[11px]"></span>
)}
</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 align="right">
<div className="flex items-center justify-end gap-2">
@@ -201,7 +207,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
</TR>,
isOpen && (
<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">
<span>
{'Εργάσιμη Μέρα: '}

View File

@@ -83,69 +83,104 @@ function SplitModal({ item, onConfirm, onClose }) {
// ─── 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 label = multiSelect
? `${items.length} αντικείμενα επιλεγμένα`
: 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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
<div className="modal-handle" />
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
{actionRows.map((a, i) => (
<button
onClick={onOrderAgain}
key={a.key}
onClick={a.onClick ? a.onClick : undefined}
disabled={!a.enabled}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '16px 4px', background: 'none', border: 'none',
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
cursor: 'pointer', textAlign: 'left',
borderBottom: i < actionRows.length - 1 ? '1px solid var(--border)' : 'none',
cursor: a.enabled ? 'pointer' : 'not-allowed',
opacity: a.enabled ? 1 : 0.45, textAlign: 'left',
}}
>
<span style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'rgba(245,158,11,0.15)',
background: a.iconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<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>
}}>{a.icon}</span>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
<div style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{a.sub}</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>
{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}>
Άκυρο
@@ -157,7 +192,16 @@ function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
// ─── 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 actions = [
{ 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: 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: 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 (
@@ -437,6 +483,32 @@ function PrintSynopsisPicker({ orderId, onClose }) {
// ─── 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 }) {
const payTotal = activeItems
.filter(i => payIds.includes(i.id))
@@ -504,6 +576,8 @@ export default function TableDetailPage() {
const [actionDataLoading, setActionDataLoading] = useState(false)
const [splitItem, setSplitItem] = useState(null)
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)
@@ -533,6 +607,12 @@ export default function TableDetailPage() {
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
useEffect(() => {
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])
}
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() {
const allActive = activeItems.map(i => i.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,
padding: '10px 12px 24px',
}}>
{/* Clear selection */}
{/* CLEAR */}
<button
onClick={() => setSelectedIds([])}
style={{
@@ -901,13 +1003,11 @@ export default function TableDetailPage() {
borderRadius: 999,
background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
καθ. επιλ.
</button>
>CLEAR</button>
{/* Select all — hidden once everything is already selected */}
{/* ALL */}
{!allActiveSelected && (
<button
onClick={selectAll}
@@ -917,10 +1017,10 @@ export default function TableDetailPage() {
borderRadius: 999,
background: 'rgba(34,197,94,0.18)', color: '#86efac',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
όλα
ALL
<span style={{
background: 'rgba(34,197,94,0.35)', color: '#86efac',
borderRadius: 999, fontSize: 11, fontWeight: 700,
@@ -929,26 +1029,25 @@ export default function TableDetailPage() {
</button>
)}
{/* Transfer items */}
{/* ACTIONS — opens item action modal */}
<button
onClick={() => {
setAllTables([])
setAllOrders([])
loadActionData()
setActionsMode('move_items')
const items = activeItems.filter(i => selectedIds.includes(i.id))
const singleStacked = items.length === 1 && items[0].quantity > 1
setItemActionTarget({ items, singleStacked, multiSelect: items.length > 1 })
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 16px',
borderRadius: 999,
background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
background: 'rgba(148,163,184,0.18)', color: '#cbd5e1',
border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
fontSize: 13, fontWeight: 700, letterSpacing: 0.5,
}}
>
μεταφορά
ACTIONS
<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,
padding: '1px 6px',
}}>{selectedIds.length}</span>
@@ -1020,10 +1119,11 @@ export default function TableDetailPage() {
</div>
)}
{/* Item action modal (long-press) */}
{/* Item action modal (long-press or ACTIONS pill) */}
{itemActionTarget && (
<ItemActionModal
target={itemActionTarget}
canCancel={canCancel}
onOrderAgain={() => {
const items = itemActionTarget.items
sessionStorage.setItem('orderAgainItems', JSON.stringify(
@@ -1042,10 +1142,34 @@ export default function TableDetailPage() {
setSplitItem(itemActionTarget.items[0])
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)}
/>
)}
{/* 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 */}
{splitItem && (
<SplitModal
@@ -1097,12 +1221,14 @@ export default function TableDetailPage() {
<ActionsSheet
order={order}
tableId={tableId}
canCancel={canCancel}
onClose={() => setShowActions(false)}
onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
onMerge={() => { setShowActions(false); setActionsMode('merge') }}
onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
onCancelOrder={() => { setShowActions(false); setCancelConfirm({ mode: 'order' }) }}
/>
)}

View File

@@ -79,7 +79,7 @@ const QUICK_ACTIONS = [
{ 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 activeItems = order?.items?.filter(i => i.status === 'active') || []
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={{
display: 'flex', alignItems: 'center', gap: 14,
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',
opacity: disabled ? 0.35 : 1, textAlign: 'left',
}}>
@@ -159,6 +159,30 @@ function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction })
</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>
@@ -358,6 +382,8 @@ export default function TableListPage() {
const [quickModal, setQuickModal] = useState(null)
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
const [canCancel, setCanCancel] = useState(false)
const [cancelConfirm, setCancelConfirm] = useState(null)
// pull-to-refresh state
const [pulling, setPulling] = useState(false)
@@ -433,6 +459,19 @@ export default function TableListPage() {
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 ───────────────────────────────────────────────────────
useEffect(() => {
if (isEmergency) return
@@ -736,12 +775,28 @@ export default function TableListPage() {
table={quickModal.table}
order={quickModal.order}
flags={quickModal.flags}
canCancel={canCancel}
onClose={() => setQuickModal(null)}
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
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
table={emergencyPayModal.table}