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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user