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:
2026-06-07 20:46:33 +03:00
parent ac7ec45279
commit d0dfd63415
10 changed files with 287 additions and 31 deletions

View File

@@ -146,6 +146,12 @@ function buildFormFromProduct(product) {
digital_image_url: product.digital_image_url ?? '',
digital_price: product.digital_price ?? '',
digital_discount: product.digital_discount ?? '',
// Phase 2A — cost tracking
cost_mode: product.cost_breakdown?.length ? 'detailed' : 'simple',
cost_simple: product.cost_simple ?? '',
cost_breakdown: product.cost_breakdown?.length
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
: [],
}
}
@@ -439,6 +445,11 @@ export default function ProductFormModal({ product, categories, printers, onSave
digital_image_url: form.digital_image_url || null,
digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null,
digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null,
// Phase 2A — cost tracking
cost_simple: form.cost_mode === 'simple' && form.cost_simple !== '' ? parseFloat(form.cost_simple) : null,
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
: null,
}
}
@@ -524,6 +535,75 @@ export default function ProductFormModal({ product, categories, printers, onSave
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
{/* Phase 2A — Cost section */}
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Κόστος</span>
<div className="flex gap-1">
{['simple', 'detailed'].map(mode => (
<button key={mode} type="button"
onClick={() => setField('cost_mode', mode)}
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
form.cost_mode === mode
? 'bg-primary-600 text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}>
{mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
</button>
))}
</div>
</div>
<div className="p-3 space-y-2">
{form.cost_mode === 'simple' ? (
<div>
<label className="label text-xs">Κόστος ανά μονάδα ()</label>
<PriceInput value={form.cost_simple} onChange={v => setField('cost_simple', v)} className="w-full" />
{form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
return (
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
</p>
)
})()}
</div>
) : (
<div className="space-y-2">
{form.cost_breakdown.map((entry, bi) => (
<div key={bi} className="flex gap-2 items-center">
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
value={entry.label}
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
<PriceInput value={entry.amount}
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
className="w-28" />
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0"></button>
</div>
))}
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
{form.cost_breakdown.length > 0 && (() => {
const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
const price = parseFloat(form.base_price) || 0
const isError = total > price && price > 0
const margin = price > 0 ? ((price - total) / price * 100) : null
return (
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
Σύνολο: {total.toFixed(2)}
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
{isError && <span className="ml-1"> Κόστος > Τιμή!</span>}
</div>
)
})()}
</div>
)}
</div>
</div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>

View File

@@ -845,10 +845,29 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
</div>
</div>
{/* Price */}
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', marginRight: 4, whiteSpace: 'nowrap' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{/* Price + cost/margin */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginRight: 4, flexShrink: 0 }}>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', whiteSpace: 'nowrap' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{(() => {
const effectiveCost = p.cost_breakdown?.length
? p.cost_breakdown.reduce((s, e) => s + (e.amount || 0), 0)
: (p.cost_simple ?? null)
if (effectiveCost === null) return null
const price = parseFloat(p.base_price)
const isError = effectiveCost > price
const margin = price > 0 ? ((price - effectiveCost) / price * 100) : null
return (
<span style={{
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
color: isError ? '#ef4444' : '#16a34a',
}}>
{isError ? '⚠ κόστος > τιμή' : margin !== null ? `${margin.toFixed(0)}% margin` : null}
</span>
)
})()}
</div>
{/* Actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>

View File

@@ -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>