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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
{'Εργάσιμη Μέρα: '}
|
||||
|
||||
Reference in New Issue
Block a user