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:
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, CHART_PALETTE } 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) }
|
||||
|
||||
export default function CategoryPerformance() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
|
||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['category-performance', from, to],
|
||||
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const categories = data?.categories || []
|
||||
const pieData = categories.map((c, i) => ({
|
||||
name: c.category_name,
|
||||
value: c.revenue,
|
||||
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
}))
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} 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>
|
||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{categories.length === 0 ? (
|
||||
<EmptyState title="Δεν υπάρχουν δεδομένα κατηγοριών" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-5">
|
||||
<Panel title="Κατανομή Εσόδων">
|
||||
<div style={{ height: 280 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
|
||||
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="col-span-7 grid grid-cols-2 gap-4">
|
||||
{categories.map((c, i) => (
|
||||
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
|
||||
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
|
||||
<span className="font-mono">{c.pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Κατηγορίες" padded={false}>
|
||||
<DataTable>
|
||||
<THead><TH>Κατηγορία</TH><TH align="right">Προϊόντα</TH><TH align="right">Τεμάχια</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">% Συνόλου</TH></THead>
|
||||
<tbody>
|
||||
{categories.map((c, i) => (
|
||||
<TR key={c.category_id} striped>
|
||||
<TD>
|
||||
<span className="inline-flex items-center gap-2 font-medium text-slate-900">
|
||||
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
|
||||
{c.category_name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD mono align="right">{c.product_count}</TD>
|
||||
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
|
||||
<TD mono align="right">{c.pct}%</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
Normal file
175
manager_dashboard/src/pages/reports/restaurant/OrderHistory.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
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 EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, 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) }
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: 'Όλες οι Καταστάσεις' },
|
||||
{ value: 'open', label: 'Ανοιχτή' },
|
||||
{ value: 'paid', label: 'Πληρωμένη' },
|
||||
{ value: 'closed', label: 'Κλειστή' },
|
||||
{ value: 'cancelled', label: 'Ακυρωμένη' },
|
||||
]
|
||||
|
||||
export default function OrderHistory() {
|
||||
const [mode, setMode] = useState('range')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [businessDayId, setBusinessDayId] = useState('all')
|
||||
const [statusF, setStatusF] = useState('all')
|
||||
const [waiterF, setWaiterF] = useState('all')
|
||||
const [tableF, setTableF] = useState('all')
|
||||
const [drillOrder, setDrillOrder] = useState(null)
|
||||
|
||||
const { data: waitersData } = useQuery({ queryKey: ['meta-waiters'], queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), staleTime: 5 * 60 * 1000 })
|
||||
const { data: tablesData } = useQuery({ queryKey: ['meta-tables'], queryFn: () => client.get('/api/reports/meta/tables').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 })
|
||||
|
||||
const queryParams = {
|
||||
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||
...(statusF !== 'all' ? { status: statusF } : {}),
|
||||
...(waiterF !== 'all' ? { waiter_id: waiterF } : {}),
|
||||
...(tableF !== 'all' ? { table_id: tableF } : {}),
|
||||
page_size: 200,
|
||||
}
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['order-history', mode, from, to, businessDayId, statusF, waiterF, tableF],
|
||||
queryFn: () => client.get('/api/reports/orders/history', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const waiterOptions = [{ value: 'all', label: 'Όλοι οι Σερβιτόροι' }, ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name })))]
|
||||
const tableOptions = [{ value: 'all', label: 'Όλα τα Τραπέζια' }, ...((tablesData?.tables || []).map(t => ({ value: String(t.id), label: t.name })))]
|
||||
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
|
||||
|
||||
const orders = Array.isArray(data) ? data : []
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={8} /></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={queryParams} filename={`orders-${from}-to-${to}.csv`} />
|
||||
}>
|
||||
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||
{mode === 'workday' ? (
|
||||
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
|
||||
) : (
|
||||
<>
|
||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||
</>
|
||||
)}
|
||||
<FilterSelect value={statusF} onChange={setStatusF} options={STATUS_OPTIONS} label="Κατάσταση" />
|
||||
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
|
||||
<FilterSelect value={tableF} onChange={setTableF} options={tableOptions} label="Τραπέζι" />
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Panel title="Παραγγελίες" subtitle={`${orders.length} αποτελέσματα`} padded={false}>
|
||||
{orders.length === 0 ? (
|
||||
<EmptyState title="Δεν βρέθηκαν παραγγελίες" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή φίλτρο κατάστασης." />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</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 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><StatusBadge status={o.status} /></TD>
|
||||
<TD mono align="right">{(o.items || []).length}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||
<TD align="right">
|
||||
<button
|
||||
onClick={() => setDrillOrder(o)}
|
||||
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>
|
||||
</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{orders.length > 200 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-2 text-center text-[11px] text-slate-500">
|
||||
Εμφάνιση πρώτων 200 από {orders.length} παραγγελίες. Περιορίστε τα φίλτρα για λιγότερα αποτελέσματα.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtDate, CHART_PALETTE } 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) }
|
||||
|
||||
export default function ProductPerformance() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [catF, setCatF] = useState('all')
|
||||
const [chartMode, setChartMode] = useState('revenue')
|
||||
|
||||
const queryParams = {
|
||||
from: from + 'T00:00:00',
|
||||
to: to + 'T23:59:59',
|
||||
...(catF !== 'all' ? { category_id: catF } : {}),
|
||||
}
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['product-performance', from, to, catF],
|
||||
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const products = data?.products || []
|
||||
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
|
||||
|
||||
const top10 = products.slice(0, 10).map((p, i) => ({
|
||||
name: p.product_name,
|
||||
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
|
||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
}))
|
||||
|
||||
const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} 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/products/export" params={queryParams} filename={`products-${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">
|
||||
<Panel
|
||||
title="Top 10 Προϊόντα"
|
||||
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
|
||||
right={
|
||||
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
|
||||
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
|
||||
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{top10.length === 0 ? (
|
||||
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||
) : (
|
||||
<div style={{ height: 280 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
|
||||
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
|
||||
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
|
||||
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
|
||||
{products.length === 0 ? (
|
||||
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
|
||||
) : (
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
|
||||
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{products.map(p => {
|
||||
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
|
||||
return (
|
||||
<TR key={p.product_id} striped>
|
||||
<TD className="font-medium text-slate-900">{p.product_name}</TD>
|
||||
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
|
||||
<TD mono align="right">{pct.toFixed(1)}%</TD>
|
||||
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
Normal file
104
manager_dashboard/src/pages/reports/restaurant/RevenueTrends.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import { fmtEUR, fmtNum, fmtDate } 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 GRAN_OPTIONS = [['daily', 'Ημερήσια'], ['weekly', 'Εβδομαδιαία'], ['monthly', 'Μηνιαία']]
|
||||
|
||||
export default function RevenueTrends() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [gran, setGran] = useState('daily')
|
||||
|
||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59', granularity: gran }
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['revenue-trends', from, to, gran],
|
||||
queryFn: () => client.get('/api/reports/revenue/trends', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const trends = data?.trends || []
|
||||
const chartData = trends.map(d => ({
|
||||
label: gran === 'monthly' ? d.date : fmtDate(d.date),
|
||||
revenue: d.revenue,
|
||||
rolling7: d.rolling7,
|
||||
}))
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={3} 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>
|
||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[12px]">
|
||||
{GRAN_OPTIONS.map(([g, label]) => (
|
||||
<button key={g} onClick={() => setGran(g)} className={`rounded px-2.5 py-1 font-medium transition ${gran === g ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${from} → ${to}`}>
|
||||
{trends.length === 0 ? (
|
||||
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
||||
) : (
|
||||
<div style={{ height: 320 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
|
||||
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="label" 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)} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
|
||||
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
|
||||
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Αναλυτικά Στοιχεία" padded={false}>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Περίοδος</TH>
|
||||
<TH align="right">Παραγγελίες</TH>
|
||||
<TH align="right">Έσοδα</TH>
|
||||
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
|
||||
</THead>
|
||||
<tbody>
|
||||
{trends.map(d => (
|
||||
<TR key={d.date} striped>
|
||||
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
|
||||
<TD mono align="right">{fmtNum(d.orders)}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
|
||||
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtMinutes } 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) }
|
||||
|
||||
export default function TableAnalytics() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
|
||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['table-analytics', from, to],
|
||||
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const tables = data?.tables || []
|
||||
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></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/tables/performance" params={queryParams} filename={`tables-${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">
|
||||
{tables.length === 0 ? (
|
||||
<EmptyState title="Δεν υπάρχουν δεδομένα τραπεζιών" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
|
||||
) : (
|
||||
<>
|
||||
<Panel title="Απόδοση Τραπεζιών" padded={false}>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Τραπέζι</TH><TH align="right">Παραγγελίες</TH><TH align="right">Μέση Διάρκεια</TH>
|
||||
<TH align="right">Μέσο / Επίσκεψη</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">Κύκλοι</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{tables.map(t => {
|
||||
const avgRev = t.order_count ? t.revenue / t.order_count : 0
|
||||
return (
|
||||
<TR key={t.table_id} striped>
|
||||
<TD className="font-medium text-slate-900">{t.table_name}</TD>
|
||||
<TD mono align="right">{fmtNum(t.order_count)}</TD>
|
||||
<TD mono align="right">{fmtMinutes(t.avg_duration_minutes)}</TD>
|
||||
<TD mono align="right">{fmtEUR(avgRev)}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(t.revenue)}</TD>
|
||||
<TD mono align="right">{fmtNum(t.order_count)}</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</Panel>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Ένταση Εσόδων" subtitle="Χρώμα κελιού = έσοδα ως προς κορυφαίο τραπέζι">
|
||||
<div className="grid grid-cols-6 gap-3 sm:grid-cols-8 md:grid-cols-10">
|
||||
{tables.map(t => {
|
||||
const intensity = t.revenue / maxRev
|
||||
return (
|
||||
<div
|
||||
key={t.table_id}
|
||||
className="aspect-square flex flex-col justify-between rounded-md p-2 ring-1 ring-inset ring-slate-200"
|
||||
style={{ backgroundColor: `rgba(96, 165, 250, ${0.08 + intensity * 0.65})` }}
|
||||
>
|
||||
<div className="font-mono text-[11px] font-semibold text-slate-700">{t.table_name}</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-slate-700">{fmtEUR(t.revenue)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
|
||||
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
|
||||
import StatCard from '../shared/StatCard'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import { fmtNum } 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 DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυρ']
|
||||
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
|
||||
|
||||
export default function TrafficAnalytics() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
|
||||
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['traffic', from, to],
|
||||
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const byHour = useMemo(() => {
|
||||
const hourMap = {}
|
||||
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
|
||||
return HOURS.map(h => hourMap[h] || { hour: h, orders: 0, revenue: 0 })
|
||||
}, [data])
|
||||
|
||||
const byWeekday = data?.by_weekday || []
|
||||
|
||||
// Build heatmap matrix [dow][hourIndex] = orders
|
||||
const matrix = useMemo(() => {
|
||||
// Backend only gives aggregated by_hour, so build a simple row from that
|
||||
return DOWS.map(() => HOURS.map(() => 0))
|
||||
}, [data])
|
||||
|
||||
const chartData = byHour.map(h => ({
|
||||
hour: `${String(h.hour).padStart(2, '0')}:00`,
|
||||
orders: h.orders,
|
||||
}))
|
||||
|
||||
const busiest = [...byHour].sort((a, b) => b.orders - a.orders)[0]
|
||||
const busiestDow = [...byWeekday].sort((a, b) => b.orders - a.orders)[0]
|
||||
const quietest = [...byHour].filter(h => h.orders > 0).sort((a, b) => a.orders - b.orders)[0]
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={4} columns={5} 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>
|
||||
)
|
||||
|
||||
const maxByHour = Math.max(1, ...byHour.map(h => h.orders))
|
||||
const maxByDow = Math.max(1, ...byWeekday.map(d => d.orders))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar>
|
||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Πιο Πολυάσχολη Ώρα" value={busiest ? `${String(busiest.hour).padStart(2, '0')}:00` : '—'} icon={Flame} />
|
||||
<StatCard label="Πιο Πολυάσχολη Μέρα" value={busiestDow?.label || '—'} icon={CalendarDays} />
|
||||
<StatCard label="Πιο Ήσυχη Ώρα" value={quietest ? `${String(quietest.hour).padStart(2, '0')}:00` : '—'} icon={Moon} />
|
||||
<StatCard label="Συνολικές Παραγγελίες" value={fmtNum(byHour.reduce((s, h) => s + h.orders, 0))} icon={TrendingUp} accent />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Παραγγελίες ανά Ώρα" subtitle="Σε όλες τις μέρες της επιλεγμένης περιόδου">
|
||||
<div style={{ height: 240 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 4, left: 4 }}>
|
||||
<CartesianGrid vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
|
||||
<Tooltip content={<ChartTooltip />} cursor={{ fill: '#f1f5f9' }} />
|
||||
<Bar dataKey="orders" fill="#60a5fa" radius={[3, 3, 0, 0]} maxBarSize={36} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{byWeekday.map((d, i) => {
|
||||
const intensity = d.orders / maxByDow
|
||||
return (
|
||||
<div key={d.day} className="flex flex-col items-center gap-2">
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-slate-500">{d.label}</div>
|
||||
<div
|
||||
className="flex h-24 w-full items-end justify-center rounded-md pb-2"
|
||||
style={{ backgroundColor: `rgba(96, 165, 250, ${0.08 + intensity * 0.7})` }}
|
||||
>
|
||||
<span className="font-mono text-[13px] font-semibold tabular-nums" style={{ color: intensity > 0.4 ? 'white' : '#334155' }}>
|
||||
{d.orders}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 DrillDownModal from '../shared/DrillDownModal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration } 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) }
|
||||
|
||||
export default function WorkDaySummary() {
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [drillId, setDrillId] = useState(null)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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 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),
|
||||
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} μέρες · ${from} → ${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>
|
||||
</THead>
|
||||
<tbody>
|
||||
{days.map(d => (
|
||||
<TR key={d.id} onClick={() => setDrillId(d.id)} 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>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user