Files
xenia-pos-local/manager_dashboard/src/pages/reports/restaurant/Today.jsx
bonamin 5da378a0ae feat: reports cost tracking, staff hourly rate, product form refactor
- Backend reports: fix cost/profit calculation to handle null unit_cost
  with has_gap flag; expose total_cost in product & workday summary
- StaffTab: add hourly_rate field in payroll section (admin-only)
- ProductFormModal: major refactor of form layout and structure
- ProductsTab: minor tweaks aligned with form changes
- Report pages (Today, WorkDaySummary, CategoryPerformance,
  ProductPerformance, RevenueTrends): UI improvements and cost data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:58:55 +03:00

205 lines
11 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 { 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, PiggyBank, ShoppingBag } 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>
{bd.trackable_profit > 0 && (
<div className="mt-4 flex items-center gap-3 border-t border-sky-100 pt-4">
<div className="flex-1">
<div className="text-[10px] font-medium uppercase tracking-[0.08em] text-slate-400">Μεικτό Κέρδος</div>
<div className="font-mono text-[22px] font-semibold tabular-nums text-green-700">
{fmtEUR(bd.trackable_profit)}
</div>
</div>
{bd.revenue > 0 && (
<div className="rounded-md bg-green-50 px-2.5 py-1 text-[13px] font-semibold text-green-700 ring-1 ring-inset ring-green-200">
{((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin
{bd.has_gap && <span className="ml-1 text-[10px] text-amber-500"> μερικό</span>}
</div>
)}
</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} />
{bd.total_cost > 0 && (
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
)}
{bd.trackable_profit > 0 && (
<StatCard
label="Μεικτό Κέρδος"
value={fmtEUR(bd.trackable_profit)}
sub={bd.has_gap ? '⚠ μερικό κέρδος' : `${((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin`}
icon={PiggyBank}
/>
)}
</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 align="right">Κέρδος</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{orders.slice(0, 30).map(o => {
const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const costedItems = activeItems.filter(i => i.unit_cost != null)
const orderProfit = costedItems.length > 0
? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
: null
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 mono align="right">
{orderProfit != null
? <span className={orderProfit >= 0 ? 'text-green-700' : 'text-red-600'}>{fmtEUR(orderProfit)}</span>
: <span className="text-slate-300"></span>
}
</TD>
<TD><StatusBadge status={o.status} pulse /></TD>
</TR>
)
})}
</tbody>
</DataTable>
</Panel>
</div>
</div>
</div>
)
}