feat: ShiftDetailModal rework, Today cancellation events, shift details icon button
- Backend: add cancellation_events (row count) to _enrich_shift and shift summary - Backend: add cancellation_events field to current business day endpoint - Today tab: hero number now shows cancellation_events (distinct cancel actions), sub-label shows 'X παραγγελίες / Y είδη' - ShiftsOverview: Λεπτομέρειες button is now icon-only (Eye, light blue) matching the delete button style - ShiftDetailModal: modal widened to 1080px; ΠΑΡΑΔΟΘΗΚΑΝ KPI replaced with Ακυρώσεις showing 'X events / Y items'; all filter labels renamed (Πλήρης Παραγγελία, Μόνο Πληρώθηκε, Μόνο Παρήγγειλε, Ανοιχτό ακόμη); new Ακυρώθηκε filter added; cancelled items shown in red, Πληρώθηκε line hidden for cancelled; legend updated; classify() handles cancelled status before other checks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -121,8 +121,12 @@ export default function Today() {
|
||||
)}
|
||||
<StatCard
|
||||
label="Ακυρώσεις"
|
||||
value={fmtNum(bd.cancellations)}
|
||||
sub={bd.cancelled_items > 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 && (
|
||||
|
||||
@@ -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: '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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '7px 14px',
|
||||
borderTop: '1px solid #f4f4f2', background: c.bg,
|
||||
}}>
|
||||
{/* colour dot */}
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: c.dot, flexShrink: 0, marginTop: 5 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: isCancelled ? '#991b1b' : '#111315' }}>
|
||||
{item.product_name}
|
||||
<span style={{ fontWeight: 400, color: '#8a9099', marginLeft: 4 }}>×{item.quantity}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#111315', flexShrink: 0 }}>{fmtEUR(item.subtotal)}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: isCancelled ? '#b91c1c' : '#111315', flexShrink: 0, textDecoration: isCancelled ? 'line-through' : 'none' }}>
|
||||
{fmtEUR(item.subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '2px 12px', marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Παρήγγειλε: <span style={{ fontWeight: 600, color: '#374151' }}>{item.added_by_name ?? '—'}</span>
|
||||
{item.added_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.added_at)}</span> : null}
|
||||
</span>
|
||||
{!isCancelled && (
|
||||
<span style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
Πληρώθηκε: <span style={{ fontWeight: 600, color: item.paid_by_name ? '#374151' : '#9ca3af' }}>{item.paid_by_name ?? '—'}</span>
|
||||
{item.paid_at ? <span style={{ color: '#9ca3af' }}> · {fmtDateTime(item.paid_at)}</span> : null}
|
||||
{item.payment_method ? <span style={{ color: '#9ca3af' }}> ({item.payment_method})</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{
|
||||
@@ -90,7 +105,6 @@ function OrderGroup({ order, shiftWaiterId, activeFilter }) {
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 10, border: '1px solid #edeff1', marginBottom: 8, overflow: 'hidden' }}>
|
||||
{/* Order header */}
|
||||
<div style={{ padding: '8px 14px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
|
||||
Παραγγελία #{order.order_id}
|
||||
@@ -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}
|
||||
>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 900,
|
||||
background: 'white', borderRadius: 20, width: '100%', maxWidth: 1080,
|
||||
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
@@ -180,8 +193,6 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
||||
{/* KPI grid — 2 rows × 4 cols */}
|
||||
{(() => {
|
||||
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,10 +215,19 @@ 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: 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 */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ dot: '#16a34a', label: 'Παρήγγειλε + Πληρώθηκε' },
|
||||
{ dot: '#2563eb', label: 'Πληρώθηκε (παρήγγηλε άλλος)' },
|
||||
{ dot: '#ca8a04', label: 'Παρήγγειλε (Πληρώθηκε άλλος)' },
|
||||
{ dot: '#ea580c', label: 'Παρήγγειλε (απλήρωτο)' },
|
||||
{ dot: '#dc2626', label: 'Πρόβλημα Δεδομένων' },
|
||||
].map(l => (
|
||||
<div key={l.dot} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
|
||||
{LEGEND.map(l => (
|
||||
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.dot, flexShrink: 0 }} />
|
||||
{l.label}
|
||||
</div>
|
||||
@@ -244,29 +258,41 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', gap: 6, padding: '10px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0, overflowX: 'auto' }}>
|
||||
{FILTER_OPTIONS.map(opt => (
|
||||
{FILTER_OPTIONS.map(opt => {
|
||||
const isCancelOpt = opt.key === 'cancelled'
|
||||
const isActive = activeFilter === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setActiveFilter(opt.key)}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
border: activeFilter === opt.key ? '1.5px solid #3758c9' : '1.5px solid #e5e7eb',
|
||||
background: activeFilter === opt.key ? '#eff6ff' : 'white',
|
||||
color: activeFilter === opt.key ? '#3758c9' : '#5a6169',
|
||||
border: isActive
|
||||
? isCancelOpt ? '1.5px solid #dc2626' : '1.5px solid #3758c9'
|
||||
: '1.5px solid #e5e7eb',
|
||||
background: isActive
|
||||
? isCancelOpt ? '#fef2f2' : '#eff6ff'
|
||||
: 'white',
|
||||
color: isActive
|
||||
? isCancelOpt ? '#991b1b' : '#3758c9'
|
||||
: '#5a6169',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{counts[opt.key] > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 5, padding: '1px 5px', borderRadius: 8, fontSize: 10,
|
||||
background: activeFilter === opt.key ? '#3758c9' : '#e5e7eb',
|
||||
color: activeFilter === opt.key ? 'white' : '#5a6169',
|
||||
background: isActive
|
||||
? isCancelOpt ? '#dc2626' : '#3758c9'
|
||||
: '#e5e7eb',
|
||||
color: isActive ? 'white' : '#5a6169',
|
||||
}}>
|
||||
{counts[opt.key]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Orders + items */}
|
||||
|
||||
@@ -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 } = {}) {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDetailShift({ id: s.id, waiter_id: s.waiter_id }) }}
|
||||
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
|
||||
className="rounded border border-sky-200 bg-white p-0.5 text-sky-400 hover:bg-sky-50 hover:text-sky-600"
|
||||
title="Λεπτομέρειες βάρδιας"
|
||||
>
|
||||
Λεπτομέρειες
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
{!s.is_active && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user