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:
@@ -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)}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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