Files
xenia-pos-local/manager_dashboard/src/pages/reports/restaurant/WorkDaySummary.jsx
bonamin ac7ec45279 feat(frontend): workday summary modal, print analytics UI, staff zone-PIN management, report upgrades
manager_dashboard:
- DashboardPage: WorkdaySummaryModal integration (view / close-day flows); revenue chart upgrades
- WorkdaySummaryModal: new component — shows full shift/revenue summary before closing the day
- StaffTab: full rewrite — zone-assignment modal, reset-PIN modal, actions dropdown, richer staff card
- ProductsTab: digital-menu fields surfaced in product form
- PrintFontsTab: expanded font-size/weight controls per printer channel
- TablesConfigTab / TablesPage / tableColourStore: color picker and zone fields for tables
- FilterBar: unified filter component with date-range, printer, and category selectors
- CategoryPerformance / ProductPerformance / TableAnalytics: donut/bar charts,
  thermal+browser print modals, richer breakdown tables
- PrinterHistory: redesigned with per-printer drill-down and summary blocks
- TrafficAnalytics / WorkDaySummary / RevenueTrends / ShiftsOverview / StaffLeaderboard: polish pass
- tokens.js: design token updates

waiter_pwa:
- TableCard: show table color indicator from zone/colour config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:28:23 +03:00

325 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import DrillDownModal from '../shared/DrillDownModal'
import OrderDetailModal from '../shared/OrderDetailModal'
import ShiftDetailModal from '../shared/ShiftDetailModal'
import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr } 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) }
// ── Workday drill-down modal ────────────────────────────────────────────────
function WorkDayModal({ day, onClose, onDeleteShift }) {
const [tab, setTab] = useState('orders')
const [drillOrder, setDrillOrder] = useState(null)
const [detailShift, setDetailShift] = useState(null)
const { data: ordersData } = useQuery({
queryKey: ['business-day-orders', day.id],
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: day.id, page_size: 200 } }).then(r => r.data),
staleTime: 0,
})
const { data: shiftsData } = useQuery({
queryKey: ['shifts-for-day', day.id],
queryFn: () => client.get('/api/reports/shifts', { params: { business_day_id: day.id } }).then(r => r.data),
staleTime: 0,
})
const orders = Array.isArray(ordersData) ? ordersData : []
const shifts = shiftsData?.shifts || []
const TABS = [
{ key: 'orders', label: `Παραγγελίες (${orders.length})` },
{ key: 'shifts', label: `Βάρδιες (${shifts.length})` },
]
return (
<>
<DrillDownModal
title={`Εργάσιμη Μέρα · ${fmtDate(day.opened_at)}`}
subtitle={`${fmtEUR(day.revenue)} έσοδα · ${fmtTime(day.opened_at)} ${day.closed_at ? fmtTime(day.closed_at) : 'ανοιχτή'}`}
onClose={onClose}
>
{/* Tabs */}
<div className="flex border-b border-slate-200 px-6">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${tab === t.key ? 'border-slate-800 text-slate-900' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
{t.label}
</button>
))}
</div>
{/* Orders tab */}
{tab === 'orders' && (
orders.length === 0
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν παραγγελίες</div>
: <DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</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)
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" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD><StatusBadge status={o.status} /></TD>
</TR>
)
})}
</tbody>
</DataTable>
)}
{/* Shifts tab */}
{tab === 'shifts' && (
shifts.length === 0
? <div className="py-12 text-center text-slate-400 text-sm">Δεν βρέθηκαν βάρδιες</div>
: <DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH>Έναρξη</TH>
<TH>Λήξη</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH>Κατάσταση</TH>
<TH className="w-20" />
</THead>
<tbody>
{shifts.map(s => (
<TR key={s.id} striped onClick={() => setDetailShift(s)} className="cursor-pointer">
<TD><WaiterAvatar name={s.waiter_name} id={s.waiter_id} /></TD>
<TD mono>{fmtDateTime(s.started_at)}</TD>
<TD mono>{s.ended_at ? fmtDateTime(s.ended_at) : <span className="text-sky-600 font-medium"> ενεργή </span>}</TD>
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right">
<div className="flex items-center justify-end gap-2">
<button
onClick={e => { e.stopPropagation(); setDetailShift(s) }}
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
>
Λεπτομέρειες
</button>
{!s.is_active && (
<button
onClick={e => { e.stopPropagation(); onDeleteShift(s) }}
className="rounded border border-red-200 bg-white p-0.5 text-red-400 hover:bg-red-50 hover:text-red-600"
title="Διαγραφή βάρδιας"
>
<Trash2 size={13} />
</button>
)}
</div>
</TD>
</TR>
))}
</tbody>
</DataTable>
)}
</DrillDownModal>
{drillOrder && (
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
)}
{detailShift && (
<ShiftDetailModal
shiftId={detailShift.id}
shiftWaiterId={detailShift.waiter_id}
onClose={() => setDetailShift(null)}
/>
)}
</>
)
}
// ── Main page ───────────────────────────────────────────────────────────────
export default function WorkDaySummary() {
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [drillDay, setDrillDay] = useState(null)
const [deleteDay, setDeleteDay] = useState(null)
const [deleteShift, setDeleteShift] = useState(null)
const qc = useQueryClient()
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['business-days', from, to],
queryFn: () => client.get('/api/reports/business-days', { params: { from: from + 'T00:00:00', to: to + 'T23:59:59' } }).then(r => r.data),
staleTime: 0,
})
const deleteDayMutation = useMutation({
mutationFn: (id) => client.delete(`/api/business-day/${id}`),
onSuccess: () => {
toast.success('Η εργάσιμη μέρα διαγράφηκε')
qc.invalidateQueries({ queryKey: ['business-days'] })
qc.invalidateQueries({ queryKey: ['business-days-list'] })
setDeleteDay(null)
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής εργάσιμης μέρας')
setDeleteDay(null)
},
})
const deleteShiftMutation = useMutation({
mutationFn: (id) => client.delete(`/api/shifts/${id}`),
onSuccess: () => {
toast.success('Η βάρδια και οι παραγγελίες της διαγράφηκαν')
qc.invalidateQueries({ queryKey: ['shifts'] })
qc.invalidateQueries({ queryKey: ['shifts-for-day'] })
qc.invalidateQueries({ queryKey: ['business-days'] })
qc.invalidateQueries({ queryKey: ['business-days-list'] })
setDeleteShift(null)
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα διαγραφής βάρδιας')
setDeleteShift(null)
},
})
const days = data?.business_days || []
const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at),
revenue: d.revenue,
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center"><button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button></div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/orders/export" params={{ from: from + 'T00:00:00', to: to + 'T23:59:59' }} filename={`workdays-${from}-to-${to}.csv`} />
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{chartData.length > 0 && (
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
<div style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Line type="monotone" dataKey="revenue" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</Panel>
)}
<div className="mt-4">
<Panel title="Εργάσιμες Μέρες" padded={false}>
{days.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν εργάσιμες μέρες" description="Δοκιμάστε ευρύτερο εύρος ημερομηνιών." />
) : (
<DataTable>
<THead>
<TH>Εργάσιμη Μέρα</TH>
<TH>Άνοιξε</TH>
<TH>Έκλεισε</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH>
<TH className="w-10" />
</THead>
<tbody>
{days.map(d => (
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
<TD mono>{fmtTime(d.opened_at)}</TD>
<TD mono>{d.closed_at ? fmtTime(d.closed_at) : '—'}</TD>
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
<TD mono align="right">{fmtNum(d.order_count)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
<TD><StatusBadge status={d.status} pulse /></TD>
<TD align="right">
{d.status === 'closed' && (
<button
onClick={e => { e.stopPropagation(); setDeleteDay(d) }}
className={`rounded border p-0.5 bg-white ${(d.shift_count ?? 0) === 0 ? 'border-red-200 text-red-400 hover:bg-red-50 hover:text-red-600' : 'border-slate-200 text-slate-300 cursor-not-allowed'}`}
title={(d.shift_count ?? 0) === 0 ? 'Διαγραφή εργάσιμης μέρας' : `Έχει ${d.shift_count} βάρδιες — διαγράψτε τες πρώτα`}
disabled={(d.shift_count ?? 0) > 0}
>
<Trash2 size={13} />
</button>
)}
</TD>
</TR>
))}
</tbody>
</DataTable>
)}
</Panel>
</div>
</div>
{drillDay && (
<WorkDayModal
day={drillDay}
onClose={() => setDrillDay(null)}
onDeleteShift={s => setDeleteShift(s)}
/>
)}
{deleteDay && (
<DeleteConfirmModal
title={`Διαγραφή Εργάσιμης Μέρας #${deleteDay.id}`}
description={`Η εργάσιμη μέρα της ${fmtDate(deleteDay.opened_at)} θα διαγραφεί μόνιμα. Δεν έχει βάρδιες.`}
onConfirm={() => deleteDayMutation.mutate(deleteDay.id)}
onCancel={() => setDeleteDay(null)}
/>
)}
{deleteShift && (
<DeleteConfirmModal
title={`Διαγραφή Βάρδιας #${deleteShift.id}`}
description={`Η βάρδια του ${deleteShift.waiter_name} (${fmtDateTime(deleteShift.started_at)}) και ΟΛΕΣ οι παραγγελίες της θα διαγραφούν μόνιμα.`}
onConfirm={() => deleteShiftMutation.mutate(deleteShift.id)}
onCancel={() => setDeleteShift(null)}
/>
)}
</div>
)
}