diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index e7b3129..961b90d 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -1263,12 +1263,17 @@ def current_business_day( # Count cancelled items (individual items cancelled, across all orders in this day) all_order_ids = [o.id for o in orders] cancelled_items_qty = 0 + cancellation_events = 0 if all_order_ids: from sqlalchemy import func as sqlfunc cancelled_items_qty = db.query(sqlfunc.sum(OrderItem.quantity)).filter( OrderItem.order_id.in_(all_order_ids), OrderItem.status == "cancelled", ).scalar() or 0 + cancellation_events = db.query(sqlfunc.count(OrderItem.id)).filter( + OrderItem.order_id.in_(all_order_ids), + OrderItem.status == "cancelled", + ).scalar() or 0 return { "business_day": { @@ -1283,6 +1288,7 @@ def current_business_day( "active_waiters": len(active_shifts), "cancellations": len(cancelled_orders), "cancelled_items": int(cancelled_items_qty), + "cancellation_events": int(cancellation_events), "top_product": top_product, } } diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py index 7bce207..148d0f9 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -84,6 +84,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict: if shift.ended_at: cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at) cancelled_rows = cancelled_q.all() + cancellation_events = len(cancelled_rows) cancellations = sum(r.quantity for r in cancelled_rows) cancellation_value = round(sum(r.unit_price * r.quantity for r in cancelled_rows), 2) return { @@ -101,6 +102,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"], + "cancellation_events": cancellation_events, "cancellations": cancellations, "cancellation_value": cancellation_value, # Phase 2E @@ -417,7 +419,7 @@ def get_shift_summary( waiter_id = shift.waiter_id - # Collect all relevant items: paid in this shift OR added by this waiter + # Collect all relevant items: paid in this shift OR added by this waiter (any status) paid_items = db.query(OrderItem).options( joinedload(OrderItem.product), joinedload(OrderItem.order), @@ -446,6 +448,22 @@ def get_shift_summary( items_by_id[item.id] = item all_items = list(items_by_id.values()) + # Also include items cancelled within this shift window (may not be in ordered_items + # if they were cancelled after the shift ended or deduped out) + cancelled_items_for_summary = db.query(OrderItem).options( + joinedload(OrderItem.product), + joinedload(OrderItem.order), + ).filter( + OrderItem.added_by == waiter_id, + OrderItem.status == "cancelled", + OrderItem.added_at >= shift.started_at, + OrderItem.added_at <= upper_bound, + ).all() + for item in cancelled_items_for_summary: + if item.id not in items_by_id: + items_by_id[item.id] = item + all_items = list(items_by_id.values()) + # Build lookup maps all_waiter_ids = set() for item in all_items: diff --git a/manager_dashboard/src/pages/reports/restaurant/Today.jsx b/manager_dashboard/src/pages/reports/restaurant/Today.jsx index e3892b1..0e16666 100644 --- a/manager_dashboard/src/pages/reports/restaurant/Today.jsx +++ b/manager_dashboard/src/pages/reports/restaurant/Today.jsx @@ -121,8 +121,12 @@ export default function Today() { )} 0 ? `${fmtNum(bd.cancelled_items)} είδη` : 'παραγγελίες'} + value={fmtNum(bd.cancellation_events ?? bd.cancellations)} + sub={ + (bd.cancellations > 0 || bd.cancelled_items > 0) + ? `${fmtNum(bd.cancellations)} παραγγελίες / ${fmtNum(bd.cancelled_items)} είδη` + : 'καμία ακύρωση' + } icon={XCircle} /> {bd.total_cost > 0 && ( diff --git a/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx b/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx index 187f59f..4429ce0 100644 --- a/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx +++ b/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx @@ -5,30 +5,41 @@ import client from '../../../api/client' import { fmtEUR, fmtDateTime, fmtTime, fmtDate } from './reportDesignTokens' // ── colour rules ──────────────────────────────────────────────────────────── -// Given the shift's waiter_id and one item, return { key, label, colors } function classify(item, shiftWaiterId) { + if (item.status === 'cancelled') + return { key: 'cancelled', label: 'Ακυρώθηκε', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#991b1b' } + const orderedByMe = item.added_by_id === shiftWaiterId const paidToMe = item.paid_by_id === shiftWaiterId const isPaid = item.status === 'paid' if (orderedByMe && paidToMe && isPaid) - return { key: 'both', label: 'Παρήγγειλε + Πληρώθηκε', dot: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' } + return { key: 'both', label: 'Πλήρης Παραγγελία', dot: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' } if (!orderedByMe && paidToMe && isPaid) - return { key: 'paid', label: 'Πληρώθηκε (άλλος παρήγγειλε)', dot: '#2563eb', bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' } + return { key: 'paid', label: 'Μόνο Πληρώθηκε', dot: '#2563eb', bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' } if (orderedByMe && isPaid && !paidToMe) - return { key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)', dot: '#ca8a04', bg: '#fefce8', border: '#fef08a', text: '#854d0e' } + return { key: 'ordered', label: 'Μόνο Παρήγγειλε', dot: '#ca8a04', bg: '#fefce8', border: '#fef08a', text: '#854d0e' } if (orderedByMe && !isPaid) - return { key: 'unpaid', label: 'Παρήγγειλε (απλήρωτο)', dot: '#ea580c', bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' } - // fallback: paid to me but status not 'paid' — data anomaly - return { key: 'anomaly', label: 'Ανωμαλία δεδομένων', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' } + return { key: 'unpaid', label: 'Ανοιχτό ακόμη', dot: '#ea580c', bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' } + return { key: 'anomaly', label: 'Πρόβλημα', dot: '#dc2626', bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' } } const FILTER_OPTIONS = [ - { key: 'all', label: 'Όλα' }, - { key: 'both', label: 'Παρήγγειλε + Πληρώθηκε' }, - { key: 'paid', label: 'Πληρώθηκε (ξένη παραγγελία)' }, - { key: 'ordered', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' }, - { key: 'unpaid', label: 'Απλήρωτα' }, + { key: 'all', label: 'Όλα' }, + { key: 'both', label: 'Πλήρης Παραγγελία' }, + { key: 'paid', label: 'Μόνο Πληρώθηκε' }, + { key: 'ordered', label: 'Μόνο Παρήγγειλε' }, + { key: 'unpaid', label: 'Ανοιχτό ακόμη' }, + { key: 'cancelled', label: 'Ακυρώθηκε' }, + { key: 'anomaly', label: 'Πρόβλημα' }, +] + +const LEGEND = [ + { dot: '#16a34a', label: 'Πλήρης Παραγγελία' }, + { dot: '#2563eb', label: 'Μόνο Πληρώθηκε' }, + { dot: '#ca8a04', label: 'Μόνο Παρήγγειλε' }, + { dot: '#ea580c', label: 'Ανοιχτό ακόμη' }, + { dot: '#dc2626', label: 'Ακυρώθηκε / Πρόβλημα' }, ] function fmtMins(mins) { @@ -40,31 +51,35 @@ function fmtMins(mins) { function ItemRow({ item, shiftWaiterId }) { const c = classify(item, shiftWaiterId) + const isCancelled = item.status === 'cancelled' return (
- {/* colour dot */}
- + {item.product_name} ×{item.quantity} - {fmtEUR(item.subtotal)} + + {fmtEUR(item.subtotal)} +
Παρήγγειλε: {item.added_by_name ?? '—'} {item.added_at ? · {fmtDateTime(item.added_at)} : null} - - Πληρώθηκε: {item.paid_by_name ?? '—'} - {item.paid_at ? · {fmtDateTime(item.paid_at)} : null} - {item.payment_method ? ({item.payment_method}) : null} - + {!isCancelled && ( + + Πληρώθηκε: {item.paid_by_name ?? '—'} + {item.paid_at ? · {fmtDateTime(item.paid_at)} : null} + {item.payment_method ? ({item.payment_method}) : null} + + )}
- {/* Order header */}
Παραγγελία #{order.order_id} @@ -130,8 +144,7 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) { staleTime: 30 * 1000, }) - // Count items per filter key for badge counts - const counts = { all: 0, both: 0, paid: 0, ordered: 0, unpaid: 0 } + const counts = { all: 0, both: 0, paid: 0, ordered: 0, unpaid: 0, cancelled: 0, anomaly: 0 } const waiterIdToUse = shiftWaiterId ?? summary?.waiter_id if (summary?.orders) { for (const o of summary.orders) { @@ -151,7 +164,7 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) { onClick={onClose} >
{ const totalOrders = summary.orders?.length ?? 0 - const tableSet = new Set(summary.orders?.map(o => o.table_name).filter(Boolean)) - const tablesServed = tableSet.size const durationHours = summary.duration_hours ?? (summary.duration_minutes != null ? summary.duration_minutes / 60 : null) const durationLabel = durationHours != null ? (() => { const h = Math.floor(durationHours); const m = Math.round((durationHours - h) * 60); return h > 0 ? `${h}ω ${m}λ` : `${m}λ` })() @@ -204,11 +215,20 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) { ? undefined : Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626' + const cancelEvents = summary.cancellation_events ?? 0 + const cancelItemsQty = summary.orders + ? summary.orders.reduce((s, o) => s + o.items.filter(i => i.status === 'cancelled').reduce((ss, i) => ss + i.quantity, 0), 0) + : 0 + const row2 = [ { label: 'Σύνολο Παραγγελιών', value: String(totalOrders) }, { label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' }, - { label: 'Παραδόθηκαν', value: summary.counted_cash_end != null ? fmtEUR(summary.counted_cash_end) : '—', accent: '#3758c9' }, - { label: 'Ταμείο', value: discLabel, accent: discAccent }, + { + label: 'Ακυρώσεις', + value: cancelEvents > 0 ? `${cancelEvents} / ${cancelItemsQty} είδη` : '—', + accent: cancelEvents > 0 ? '#dc2626' : undefined, + }, + { label: 'Ταμείο', value: discLabel, accent: discAccent }, ] return (
@@ -228,14 +248,8 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) { {/* Colour legend */}
- {[ - { dot: '#16a34a', label: 'Παρήγγειλε + Πληρώθηκε' }, - { dot: '#2563eb', label: 'Πληρώθηκε (παρήγγηλε άλλος)' }, - { dot: '#ca8a04', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' }, - { dot: '#ea580c', label: 'Παρήγγειλε (απλήρωτο)' }, - { dot: '#dc2626', label: 'Πρόβλημα Δεδομένων' }, - ].map(l => ( -
+ {LEGEND.map(l => ( +
{l.label}
@@ -244,29 +258,41 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) { {/* Filter bar */}
- {FILTER_OPTIONS.map(opt => ( - - ))} + {FILTER_OPTIONS.map(opt => { + const isCancelOpt = opt.key === 'cancelled' + const isActive = activeFilter === opt.key + return ( + + ) + })}
{/* Orders + items */} diff --git a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx index 83e4b7f..4b7b168 100644 --- a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx +++ b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { UserRoundX, ChevronRight, ExternalLink, Trash2 } from 'lucide-react' +import { UserRoundX, ChevronRight, ExternalLink, Trash2, Eye } from 'lucide-react' import toast from 'react-hot-toast' import ShiftDetailModal from '../shared/ShiftDetailModal' import DeleteConfirmModal from '../../../ui/DeleteConfirmModal' @@ -194,9 +194,10 @@ export default function ShiftsOverview({ onNavigate } = {}) {
{!s.is_active && (