feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
166
manager_dashboard/src/pages/reports/restaurant/Today.jsx
Normal file
166
manager_dashboard/src/pages/reports/restaurant/Today.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import StatCard from '../shared/StatCard'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
|
||||
|
||||
export default function Today() {
|
||||
const { data: bdData, isLoading: bdLoading } = useQuery({
|
||||
queryKey: ['business-day-current'],
|
||||
queryFn: () => client.get('/api/reports/business-days/current').then(r => r.data),
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 30 * 1000,
|
||||
})
|
||||
|
||||
const bd = bdData?.business_day
|
||||
|
||||
const { data: ordersData, isLoading: ordersLoading } = useQuery({
|
||||
queryKey: ['today-orders', bd?.id],
|
||||
queryFn: () => client.get('/api/reports/orders/history', { params: { business_day_id: bd.id, page_size: 200 } }).then(r => r.data),
|
||||
enabled: !!bd?.id,
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 30 * 1000,
|
||||
})
|
||||
|
||||
const { data: trafficData } = useQuery({
|
||||
queryKey: ['today-traffic', bd?.id],
|
||||
queryFn: () => client.get('/api/reports/traffic', { params: { business_day_id: bd.id } }).then(r => r.data),
|
||||
enabled: !!bd?.id,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
if (bdLoading || ordersLoading) {
|
||||
return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={8} showChart /></div>
|
||||
}
|
||||
|
||||
if (!bd) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar>
|
||||
<span className="font-mono text-[12px] uppercase tracking-wider text-slate-500">Live snapshot</span>
|
||||
</FilterBar>
|
||||
<div className="flex flex-1 items-center justify-center p-12">
|
||||
<EmptyState
|
||||
icon={Sunset}
|
||||
title="Δεν υπάρχει ενεργή εργάσιμη μέρα"
|
||||
description="Ανοίξτε μια εργάσιμη μέρα από τον Πίνακα Ελέγχου για να ξεκινήσετε."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const orders = Array.isArray(ordersData) ? ordersData : []
|
||||
const hourlyMap = {}
|
||||
;(trafficData?.by_hour || []).forEach(h => { hourlyMap[h.hour] = h })
|
||||
const hours = Array.from({ length: 14 }, (_, i) => 10 + i)
|
||||
const hourlyChart = hours.map(h => ({
|
||||
hour: `${String(h).padStart(2, '0')}:00`,
|
||||
revenue: hourlyMap[h]?.revenue || 0,
|
||||
}))
|
||||
const nowH = new Date().getHours()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar right={
|
||||
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-2.5 py-1 text-[12px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
||||
<span className="relative h-1.5 w-1.5 rounded-full bg-emerald-500">
|
||||
<span className="absolute inset-0 rounded-full bg-emerald-500 animate-ping opacity-75" />
|
||||
</span>
|
||||
Εργάσιμη μέρα ανοιχτή · άνοιξε {fmtTime(bd.opened_at)}
|
||||
</div>
|
||||
}>
|
||||
<span className="font-mono text-[12px] uppercase tracking-wider text-slate-500">Ζωντανή Εικόνα</span>
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-5 rounded-lg border border-slate-200 bg-gradient-to-br from-sky-50 to-white p-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-slate-500">Έσοδα Μέχρι Τώρα</div>
|
||||
<TrendingUp className="h-4 w-4 text-sky-400" />
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[52px] font-medium tabular-nums leading-none tracking-tight text-slate-900">
|
||||
{fmtEUR(bd.revenue)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4 text-[12px] text-slate-600">
|
||||
<span><span className="font-mono font-semibold text-slate-900">{bd.orders_closed}</span> κλειστές παραγγελίες</span>
|
||||
<span>·</span>
|
||||
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-7 grid grid-cols-3 gap-4">
|
||||
<StatCard label="Κλειστές Παραγγελίες" value={fmtNum(bd.orders_closed)} sub="πληρωμένες + κλειστές" icon={ReceiptText} />
|
||||
<StatCard label="Ανοιχτές Παραγγελίες" value={fmtNum(bd.orders_open)} sub="αναμένουν πληρωμή" icon={Clock} accent />
|
||||
<StatCard label="Ενεργοί Σερβιτόροι" value={fmtNum(bd.active_waiters)} icon={Users} />
|
||||
{bd.top_product && (
|
||||
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
|
||||
)}
|
||||
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Έσοδα ανά Ώρα" subtitle="Μόνο κλειστές παραγγελίες" right={<span className="font-mono text-[11px] uppercase tracking-wider text-slate-500">Σήμερα</span>}>
|
||||
<div style={{ height: 220 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={hourlyChart} margin={{ top: 8, right: 8, bottom: 4, left: 4 }}>
|
||||
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 11, 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, n) => fmtEUR(v)} />} cursor={{ fill: '#f1f5f9' }} />
|
||||
<Bar dataKey="revenue" radius={[3, 3, 0, 0]} maxBarSize={48}>
|
||||
{hourlyChart.map((entry, i) => (
|
||||
<Cell key={i} fill={parseInt(entry.hour) > nowH ? '#e2e8f0' : '#60a5fa'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Παραγγελίες Σήμερα" subtitle={`${orders.length} συνολικά`} padded={false} right={
|
||||
<ExportButton
|
||||
endpoint="/api/reports/orders/export"
|
||||
params={{ business_day_id: bd.id }}
|
||||
filename={`today-orders-${bd.id}.csv`}
|
||||
/>
|
||||
}>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{orders.slice(0, 30).map(o => {
|
||||
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).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 mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
<TD mono>{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} pulse /></TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user