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>
This commit is contained in:
2026-06-08 09:58:55 +03:00
parent 34b58b3f2e
commit 5da378a0ae
10 changed files with 673 additions and 330 deletions

View File

@@ -286,6 +286,7 @@ export default function CategoryPerformance() {
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const [pieMetric, setPieMetric] = useState('revenue')
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
@@ -313,7 +314,15 @@ export default function CategoryPerformance() {
const allProducts = allProductsData?.products || []
const soldProducts = productsData?.products || []
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] }))
const anyHasCost = categories.some(c => (c.total_cost || 0) > 0)
const pieCategories = pieMetric === 'profit'
? categories.filter(c => (c.trackable_profit || 0) > 0)
: categories
const pieData = pieCategories.map((c, i) => ({
name: c.category_name,
value: pieMetric === 'profit' ? c.trackable_profit : c.revenue,
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
}))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
@@ -352,49 +361,109 @@ export default function CategoryPerformance() {
<>
<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 title={pieMetric === 'profit' ? 'Κατανομή Κέρδους' : 'Κατανομή Εσόδων'} right={
anyHasCost ? (
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['profit', 'Κέρδος']].map(([m, label]) => (
<button key={m} onClick={() => setPieMetric(m)} className={`rounded px-2 py-1 font-medium transition ${pieMetric === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
))}
</div>
) : null
}>
{pieData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-[12px] text-slate-400">Δεν υπάρχουν δεδομένα κέρδους</div>
) : (
<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>
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<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_rev ?? c.pct}%</span>
</div>
{(c.trackable_profit || 0) > 0 && (
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
<span className="text-[11px] font-medium text-green-700">{fmtEUR(c.trackable_profit)} κέρδος</span>
{margin && <span className="text-[10px] text-slate-400">{margin}{c.has_gap && ' ⚠'}</span>}
</div>
)}
</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>
<THead>
<TH>Κατηγορία</TH>
<TH align="right">Προϊόντα</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">% Εσόδων</TH>
{anyHasCost && <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>
))}
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<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>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(c.total_cost || 0) > 0 ? fmtEUR(c.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(c.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(c.trackable_profit)}
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin}){c.has_gap && ' ⚠'}</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
)}
<TD mono align="right">{c.pct_rev ?? c.pct}%</TD>
{anyHasCost && (
<TD mono align="right">
{(c.pct_profit || 0) > 0
? <span className="text-green-700">{c.pct_profit}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
</TR>
)
})}
</tbody>
</DataTable>
</Panel>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Legend } from 'recharts'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -262,6 +262,8 @@ export default function ProductPerformance() {
const [catF, setCatF] = useState('all')
const [chartMode, setChartMode] = useState('revenue')
const [showPrintModal, setShowPrintModal] = useState(false)
// 'revenue' | 'units' | 'profit' | 'dual'
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
@@ -285,11 +287,24 @@ export default function ProductPerformance() {
const products = data?.products || []
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
const anyHasCost = products.some(p => (p.total_cost || 0) > 0)
const top10 = [...products]
.sort((a, b) => chartMode === 'revenue' ? b.revenue - a.revenue : b.qty_sold - a.qty_sold)
.sort((a, b) => {
if (chartMode === 'units') return b.qty_sold - a.qty_sold
if (chartMode === 'profit') return (b.trackable_profit || 0) - (a.trackable_profit || 0)
// revenue and dual: sort by revenue
return b.revenue - a.revenue
})
.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] }))
.map((p, i) => ({
name: p.product_name,
revenue: p.revenue,
profit: p.has_gap ? null : (p.trackable_profit || 0),
units: p.qty_sold,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
@@ -325,35 +340,66 @@ export default function ProductPerformance() {
</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>
{(() => {
const chartModes = [
['revenue', 'Έσοδα'],
['units', 'Τεμάχια'],
...(anyHasCost ? [['profit', 'Κέρδος'], ['dual', 'Έσοδα vs Κέρδος']] : []),
]
const subtitleMap = { revenue: 'έσοδα', units: 'τεμάχια', profit: 'κέρδος', dual: 'έσοδα vs κέρδος' }
const isDual = chartMode === 'dual'
return (
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${subtitleMap[chartMode] || 'έσοδα'}`} right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{chartModes.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: isDual ? 320 : 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: isDual ? 16 : 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 === 'units' ? String(v) : '€' + v} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={(v, name) => {
if (chartMode === 'units') return v + ' τεμ.'
return fmtEUR(v)
}} />} cursor={{ fill: '#f1f5f9' }} />
{isDual && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
{isDual ? (
<>
<Bar dataKey="revenue" name="Έσοδα" fill="#60a5fa" radius={[0, 0, 0, 0]} barSize={10} />
<Bar dataKey="profit" name="Κέρδος" fill="#34d399" radius={[0, 3, 3, 0]} barSize={10} />
</>
) : (
<Bar dataKey={chartMode === 'units' ? 'units' : chartMode === 'profit' ? 'profit' : 'revenue'}
radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => (
<Cell key={i} fill={chartMode === 'profit' ? '#34d399' : d.color} />
))}
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
)
})()}
<div className="mt-4">
{(() => {
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
const totalCost = products.reduce((s, p) => s + (p.total_cost || 0), 0)
const hasGap = products.some(p => p.has_gap)
const overallMargin = totalRev > 0 && !hasGap
? ((totalProfit / totalRev) * 100).toFixed(0) + '%'
: null
return (
<Panel
title="Όλα τα Προϊόντα"
@@ -378,15 +424,20 @@ export default function ProductPerformance() {
<TH>Προϊόν</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
<TH align="right">Κέρδος</TH>
<TH align="right">Απόβλητα</TH>
<TH align="right">% Συνόλου</TH>
<TH align="right">% Έσόδων</TH>
{anyHasCost && <TH align="right">% Κέρδους</TH>}
<TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
const margin = p.revenue > 0 && !p.has_gap
const pctRev = totalRev ? (p.revenue / totalRev * 100) : 0
const pctProfit = totalProfit > 0 && !p.has_gap
? (p.trackable_profit / totalProfit * 100)
: null
const margin = p.revenue > 0 && !p.has_gap && (p.total_cost || 0) > 0
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
: null
return (
@@ -394,9 +445,19 @@ export default function ProductPerformance() {
<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>
{anyHasCost && (
<TD mono align="right">
{(p.total_cost || 0) > 0
? <span className="text-slate-600">{fmtEUR(p.total_cost)}</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">
{p.has_gap ? (
<span className="text-amber-500 text-[11px]"> χωρίς κόστος</span>
) : (p.total_cost || 0) === 0 ? (
<span className="text-slate-300"></span>
) : (
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
{fmtEUR(p.trackable_profit)}
@@ -412,7 +473,15 @@ export default function ProductPerformance() {
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{pctRev.toFixed(1)}%</TD>
{anyHasCost && (
<TD mono align="right">
{pctProfit != null
? <span className="text-green-700">{pctProfit.toFixed(1)}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
@@ -423,14 +492,26 @@ export default function ProductPerformance() {
<TR>
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-slate-600">{fmtEUR(totalCost)}</TD>
)}
<TD mono align="right" className="font-bold">
{hasGap ? (
<span className="text-amber-600">{fmtEUR(totalProfit)} {' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : (
<span className="text-green-700">{fmtEUR(totalProfit)}</span>
)}
<span className="text-amber-600">{fmtEUR(totalProfit)}{' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : totalProfit > 0 ? (
<span className="text-green-700">
{fmtEUR(totalProfit)}
{overallMargin && <span className="ml-1 text-[10px] text-slate-400">({overallMargin})</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD />
<TD mono align="right" className="font-bold">100%</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-green-700">
{totalProfit > 0 && !hasGap ? '100%' : '—'}
</TD>
)}
<TD />
</TR>
</tfoot>

View File

@@ -27,9 +27,11 @@ export default function RevenueTrends() {
})
const trends = data?.trends || []
const anyHasProfit = trends.some(d => (d.profit || 0) > 0)
const chartData = trends.map(d => ({
label: gran === 'monthly' ? d.date : fmtDate(d.date),
revenue: d.revenue,
profit: (d.profit || 0) > 0 ? d.profit : undefined,
rolling7: d.rolling7,
}))
@@ -69,6 +71,7 @@ export default function RevenueTrends() {
<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' }} />
{anyHasProfit && <Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} connectNulls={false} />}
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
</LineChart>
</ResponsiveContainer>
@@ -83,6 +86,7 @@ export default function RevenueTrends() {
<TH>Περίοδος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasProfit && <TH align="right">Κέρδος</TH>}
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
</THead>
<tbody>
@@ -91,6 +95,17 @@ export default function RevenueTrends() {
<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>
{anyHasProfit && (
<TD mono align="right">
{(d.profit || 0) > 0
? <span className="text-green-700">
{fmtEUR(d.profit)}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
</TR>
))}

View File

@@ -1,6 +1,6 @@
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 { 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'
@@ -94,6 +94,22 @@ export default function Today() {
<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">
@@ -104,6 +120,17 @@ export default function Today() {
<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>
@@ -138,11 +165,16 @@ export default function Today() {
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</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 total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
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' : ''}>
@@ -152,6 +184,12 @@ export default function Today() {
<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>
)

View File

@@ -2,7 +2,7 @@ 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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } 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'
@@ -204,9 +204,11 @@ export default function WorkDaySummary() {
const days = data?.business_days || []
const anyHasCost = days.some(d => (d.total_cost || 0) > 0)
const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at),
revenue: d.revenue,
profit: (d.trackable_profit || 0) > 0 ? d.trackable_profit : undefined,
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
@@ -229,14 +231,18 @@ export default function WorkDaySummary() {
<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 }}>
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: anyHasCost ? 16 : 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 }} />
{anyHasCost && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
{anyHasCost && (
<Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} activeDot={{ r: 5 }} connectNulls={false} />
)}
</LineChart>
</ResponsiveContainer>
</div>
@@ -256,13 +262,19 @@ export default function WorkDaySummary() {
<TH align="right">Διάρκεια</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH>
<TH className="w-10" />
</THead>
<tbody>
{days.map(d => (
{days.map(d => {
const margin = d.revenue > 0 && (d.trackable_profit || 0) > 0
? ((d.trackable_profit / d.revenue) * 100).toFixed(0) + '%'
: null
return (
<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>
@@ -270,6 +282,22 @@ export default function WorkDaySummary() {
<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>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(d.total_cost || 0) > 0 ? fmtEUR(d.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(d.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(d.trackable_profit)}
{margin && !d.has_gap && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
) : <span className="text-slate-300"></span>}
</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>
@@ -286,7 +314,7 @@ export default function WorkDaySummary() {
)}
</TD>
</TR>
))}
)})}
</tbody>
</DataTable>
)}