feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } 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 EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
@@ -20,11 +20,11 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'cancelled', label: 'Ακυρωμένη' },
|
||||
]
|
||||
|
||||
export default function OrderHistory() {
|
||||
const [mode, setMode] = useState('range')
|
||||
export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
const [mode, setMode] = useState(initialBusinessDayId ? 'workday' : 'range')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [businessDayId, setBusinessDayId] = useState('all')
|
||||
const [businessDayId, setBusinessDayId] = useState(initialBusinessDayId ? String(initialBusinessDayId) : 'all')
|
||||
const [statusF, setStatusF] = useState('all')
|
||||
const [waiterF, setWaiterF] = useState('all')
|
||||
const [tableF, setTableF] = useState('all')
|
||||
@@ -95,12 +95,12 @@ export default function OrderHistory() {
|
||||
</THead>
|
||||
<tbody>
|
||||
{orders.slice(0, 200).map(o => {
|
||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = (o.items || []).filter(i => i.status !== 'cancelled').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' : ''}>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_id}</TD>
|
||||
<TD>{o.table_name ?? o.table_id}</TD>
|
||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
<TD mono>{fmtDateTime(o.closed_at)}</TD>
|
||||
<TD><StatusBadge status={o.status} /></TD>
|
||||
@@ -130,45 +130,7 @@ export default function OrderHistory() {
|
||||
</div>
|
||||
|
||||
{drillOrder && (
|
||||
<DrillDownModal
|
||||
title={`Order #${drillOrder.id}`}
|
||||
subtitle={`${fmtDateTime(drillOrder.opened_at)} · Τραπέζι ${drillOrder.table_id}`}
|
||||
onClose={() => setDrillOrder(null)}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<StatusBadge status={drillOrder.status} />
|
||||
{drillOrder.notes && (
|
||||
<span className="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-800 ring-1 ring-inset ring-amber-200">
|
||||
Σημείωση: {drillOrder.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Προϊόν</TH><TH align="right">Ποσ.</TH><TH align="right">Τιμή</TH><TH align="right">Υποσύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{(drillOrder.items || []).map((item, i) => (
|
||||
<TR key={i} striped>
|
||||
<TD className="font-medium">{item.product?.name ?? `#${item.product_id}`}</TD>
|
||||
<TD mono align="right">×{item.quantity}</TD>
|
||||
<TD mono align="right">{fmtEUR(item.unit_price)}</TD>
|
||||
<TD mono align="right" className="font-semibold">{fmtEUR(item.unit_price * item.quantity)}</TD>
|
||||
<TD><StatusBadge status={item.status} /></TD>
|
||||
</TR>
|
||||
))}
|
||||
<tr className="bg-slate-50">
|
||||
<TD colSpan={3} className="py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-slate-500">Σύνολο</TD>
|
||||
<TD mono align="right" className="py-3 text-base font-semibold text-slate-900">
|
||||
{fmtEUR((drillOrder.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0))}
|
||||
</TD>
|
||||
<TD />
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</DrillDownModal>
|
||||
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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, ChartTooltip } from '../shared/TablePrimitives'
|
||||
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 } from '../shared/reportDesignTokens'
|
||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } 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 [drillId, setDrillId] = useState(null)
|
||||
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: 60 * 1000,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const { data: drillData } = useQuery({
|
||||
queryKey: ['business-day-orders', drillId],
|
||||
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: drillId, page_size: 200 } }).then(r => r.data),
|
||||
enabled: !!drillId,
|
||||
staleTime: 60 * 1000,
|
||||
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 drillDay = drillId ? days.find(d => d.id === drillId) : null
|
||||
const drillOrders = Array.isArray(drillData) ? drillData : []
|
||||
|
||||
const chartData = [...days].reverse().map(d => ({
|
||||
date: fmtDate(d.opened_at),
|
||||
@@ -90,10 +259,11 @@ export default function WorkDaySummary() {
|
||||
<TH align="right">Ακυρώσεις</TH>
|
||||
<TH align="right">Σερβιτόροι</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH className="w-10" />
|
||||
</THead>
|
||||
<tbody>
|
||||
{days.map(d => (
|
||||
<TR key={d.id} onClick={() => setDrillId(d.id)} striped>
|
||||
<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>
|
||||
@@ -103,6 +273,18 @@ export default function WorkDaySummary() {
|
||||
<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>
|
||||
@@ -113,32 +295,29 @@ export default function WorkDaySummary() {
|
||||
</div>
|
||||
|
||||
{drillDay && (
|
||||
<DrillDownModal
|
||||
title={`Εργάσιμη Μέρα · ${fmtDate(drillDay.opened_at)}`}
|
||||
subtitle={`${drillOrders.length} παραγγελίες · ${fmtEUR(drillDay.revenue)} έσοδα`}
|
||||
onClose={() => setDrillId(null)}
|
||||
>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{drillOrders.map(o => {
|
||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
return (
|
||||
<TR key={o.id} striped>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_id}</TD>
|
||||
<TD mono>{fmtDate(o.opened_at)} {fmtTime(o.opened_at)}</TD>
|
||||
<TD mono>{o.closed_at ? fmtTime(o.closed_at) : '—'}</TD>
|
||||
<TD mono align="right" className="font-semibold">{fmtEUR(total)}</TD>
|
||||
<TD><StatusBadge status={o.status} /></TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</DrillDownModal>
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user