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

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

@@ -63,8 +63,9 @@ function ShiftSettingsSection() {
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
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>
{'Εργάσιμη Μέρα: '}