feat: Phase 2A — product cost tracking
- Products: add cost_simple (flat €) and cost_breakdown (JSON line items) - OrderItems: add unit_cost snapshot written at time of order creation - Snapshot logic: breakdown sum takes priority over cost_simple; NULL if neither set - Product schema: serialize/deserialize cost_breakdown as JSON; expose in API - Product performance report: add trackable_profit, uncosted_revenue, has_gap per product - Manager UI: cost section in product edit form (simple / detailed toggle with live margin %) - Products list: show margin % or cost-error badge per product - Product performance report: profit column + gap warning banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -350,29 +350,87 @@ export default function ProductPerformance() {
|
||||
</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>
|
||||
{(() => {
|
||||
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 hasGap = products.some(p => p.has_gap)
|
||||
return (
|
||||
<Panel
|
||||
title="Όλα τα Προϊόντα"
|
||||
subtitle={`${products.length} προϊόντα με πωλήσεις`}
|
||||
padded={false}
|
||||
>
|
||||
{hasGap && (
|
||||
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||||
<span className="shrink-0 font-bold">⚠</span>
|
||||
<span>
|
||||
Μερικά προϊόντα δεν έχουν κόστος — το κέρδος που φαίνεται είναι μερικό.
|
||||
Ορισμένα έσοδα <strong>{fmtEUR(totalUncostedRev)}</strong> δεν συμπεριλαμβάνονται στο κέρδος.{' '}
|
||||
<a href="/management?tab=products" className="underline hover:text-amber-900">Ορισμός κόστους προϊόντων →</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<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
|
||||
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
|
||||
: null
|
||||
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">
|
||||
{p.has_gap ? (
|
||||
<span className="text-amber-500 text-[11px]">⚠ χωρίς κόστος</span>
|
||||
) : (
|
||||
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
|
||||
{fmtEUR(p.trackable_profit)}
|
||||
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
|
||||
</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD mono align="right">{pct.toFixed(1)}%</TD>
|
||||
<TD mono align="right">{fmtNum(p.order_count)}</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
{products.length > 1 && (
|
||||
<tfoot>
|
||||
<TR>
|
||||
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
|
||||
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</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>
|
||||
)}
|
||||
</TD>
|
||||
<TD mono align="right" className="font-bold">100%</TD>
|
||||
<TD />
|
||||
</TR>
|
||||
</tfoot>
|
||||
)}
|
||||
</DataTable>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user