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:
2026-06-09 17:48:30 +03:00
parent 8533a1452b
commit 02ec1aa28f
5 changed files with 120 additions and 65 deletions

View File

@@ -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 && (

View File

@@ -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 (
<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>
<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>
{!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,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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
@@ -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 => (
<button
key={opt.key}
onClick={() => setActiveFilter(opt.key)}
style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
border: activeFilter === opt.key ? '1.5px solid #3758c9' : '1.5px solid #e5e7eb',
background: activeFilter === opt.key ? '#eff6ff' : 'white',
color: activeFilter === opt.key ? '#3758c9' : '#5a6169',
}}
>
{opt.label}
{counts[opt.key] > 0 && (
<span style={{
marginLeft: 5, padding: '1px 5px', borderRadius: 8, fontSize: 10,
background: activeFilter === opt.key ? '#3758c9' : '#e5e7eb',
color: activeFilter === opt.key ? 'white' : '#5a6169',
}}>
{counts[opt.key]}
</span>
)}
</button>
))}
{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: 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: isActive
? isCancelOpt ? '#dc2626' : '#3758c9'
: '#e5e7eb',
color: isActive ? 'white' : '#5a6169',
}}>
{counts[opt.key]}
</span>
)}
</button>
)
})}
</div>
{/* Orders + items */}

View File

@@ -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