feat: Phase 2H — void/waste log

Backend:
- New model: WasteLog (waste_log table) — product, quantity (float), reason, reason_notes,
  unit_cost_snapshot (copied at log time from product's effective cost), total_cost (stored),
  logged_by, business_day_id
- Cost snapshot: same logic as Phase 2A — breakdown sum first, fallback to cost_simple, null if neither
- New router /api/waste/:
  - GET / — list entries (?business_day_id= or ?from=&to=), ordered by logged_at desc
  - POST / — log waste; snapshots cost; attaches to open business day automatically
  - DELETE /{id} — manager only; only allowed within the same open business day
  - GET /summary — totals by reason + by product for a period
- product_performance report: waste_qty + waste_cost added per product from the same period;
  products with only waste (no sales) also included
- Migration: CREATE TABLE IF NOT EXISTS waste_log

Frontend:
- WastePage (/waste): fast log form (product picker, quantity, reason chips, notes);
  today's entries list below (scoped to active business day); cost shown per entry;
  delete button for today's entries; history section behind toggle with date-range picker
- ProductPerformance report: Απόβλητα column showing waste_qty + cost in amber
- Sidebar: Trash2 icon for Αποβλήτα (after Καρτέλες)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:19:33 +03:00
parent 2ff48730f7
commit 9cfbde0e3e
9 changed files with 607 additions and 1 deletions

View File

@@ -0,0 +1,296 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Constants ─────────────────────────────────────────────────────────────────
const REASONS = [
{ id: 'kitchen_error', label: 'Λάθος κουζίνας', color: '#dc2626' },
{ id: 'dropped', label: 'Έπεσε', color: '#d97706' },
{ id: 'expired', label: 'Έληξε', color: '#7c3aed' },
{ id: 'other', label: 'Άλλο', color: '#6b7280' },
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function fmtDateTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// ── Log form ──────────────────────────────────────────────────────────────────
function LogForm({ products, onLog, isPending }) {
const [productId, setProductId] = useState('')
const [qty, setQty] = useState('1')
const [reason, setReason] = useState('kitchen_error')
const [notes, setNotes] = useState('')
const canLog = productId && parseFloat(qty) > 0
function submit() {
if (!canLog) return
onLog({
product_id: Number(productId),
quantity: parseFloat(qty),
reason,
reason_notes: notes.trim() || null,
})
setProductId('')
setQty('1')
setReason('kitchen_error')
setNotes('')
}
const inputStyle = {
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
boxSizing: 'border-box',
}
return (
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 14, padding: '16px 20px', background: '#fafafa' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 14 }}>
Καταγραφή Απόβλητου
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Προϊόν *</label>
<select style={inputStyle} value={productId} onChange={e => setProductId(e.target.value)} autoFocus>
<option value=""> Επιλέξτε προϊόν </option>
{products.map(p => (
<option key={p.id} value={p.id}>{p.name}{p.category_name ? ` (${p.category_name})` : ''}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Ποσότητα *</label>
<input type="number" step="0.5" min="0.1"
style={inputStyle} value={qty} onChange={e => setQty(e.target.value)} />
</div>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>Αιτία</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{REASONS.map(r => (
<button key={r.id} type="button" onClick={() => setReason(r.id)}
style={{
padding: '5px 12px', borderRadius: 20, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${reason === r.id ? r.color : '#e5e7eb'}`,
background: reason === r.id ? r.color : 'white',
color: reason === r.id ? 'white' : '#6b7280',
}}>
{r.label}
</button>
))}
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span>
</label>
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)}
placeholder="π.χ. Ο πελάτης επέστρεψε ελαττωματικό…" />
</div>
<button onClick={submit} disabled={!canLog || isPending}
style={{
padding: '9px 22px', borderRadius: 9, border: 'none', fontSize: 13.5, fontWeight: 700, cursor: canLog ? 'pointer' : 'default',
background: canLog ? '#111827' : '#e5e7eb', color: canLog ? 'white' : '#9ca3af',
}}>
{isPending ? 'Καταγραφή…' : '+ Καταγραφή Απόβλητου'}
</button>
</div>
)
}
// ── Waste entry row ───────────────────────────────────────────────────────────
function WasteRow({ entry, canDelete, onDelete }) {
const reasonCfg = REASONS.find(r => r.id === entry.reason) || { label: entry.reason, color: '#6b7280' }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'flex', alignItems: 'center', gap: 8 }}>
{entry.product_name}
<span style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>×{entry.quantity}</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 7px', borderRadius: 99, background: `${reasonCfg.color}18`, color: reasonCfg.color }}>
{reasonCfg.label}
</span>
</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 2 }}>
{fmtDateTime(entry.logged_at)} · {entry.logged_by_name}
{entry.reason_notes && <span style={{ color: '#6b7280', marginLeft: 8 }}> {entry.reason_notes}</span>}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{entry.total_cost != null ? (
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#dc2626' }}>{fmt(entry.total_cost)}</div>
) : (
<div style={{ fontSize: 12, color: '#d1d5db' }}>χωρίς κόστος</div>
)}
{entry.unit_cost_snapshot != null && (
<div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(entry.unit_cost_snapshot)}/τεμ.</div>
)}
</div>
{canDelete && (
<button onClick={() => { if (window.confirm('Διαγραφή καταχώρησης;')) onDelete(entry.id) }}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', color: '#ef4444', fontSize: 12, cursor: 'pointer', flexShrink: 0 }}>
</button>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function WastePage() {
const qc = useQueryClient()
const [showHistory, setShowHistory] = useState(false)
const [histFrom, setHistFrom] = useState(() => new Date().toISOString().slice(0, 10))
const [histTo, setHistTo] = useState(() => new Date().toISOString().slice(0, 10))
const { data: productsData = [] } = useQuery({
queryKey: ['meta-products'],
queryFn: () => client.get('/api/reports/meta/products').then(r => r.data.products),
staleTime: 5 * 60_000,
})
const { data: bdData } = useQuery({
queryKey: ['business-day'],
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
staleTime: 30_000,
})
const activeDayId = bdData?.id ?? null
// Today's entries — scoped to active business day (or all if no active day)
const { data: todayData } = useQuery({
queryKey: ['waste-today', activeDayId],
queryFn: () => client.get('/api/waste/', { params: activeDayId ? { business_day_id: activeDayId } : {} }).then(r => r.data),
staleTime: 15_000,
})
// Historical entries
const { data: histData, refetch: refetchHist } = useQuery({
queryKey: ['waste-history', histFrom, histTo],
queryFn: () => client.get('/api/waste/', { params: { from: histFrom + 'T00:00:00', to: histTo + 'T23:59:59' } }).then(r => r.data),
staleTime: 30_000,
enabled: showHistory,
})
const todayEntries = todayData?.entries || []
const histEntries = histData?.entries || []
const todayCost = todayEntries.reduce((s, e) => s + (e.total_cost || 0), 0)
const todayCount = todayEntries.length
const logWaste = useMutation({
mutationFn: (body) => client.post('/api/waste/', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['waste-today'] })
qc.invalidateQueries({ queryKey: ['waste-history'] })
toast.success('Καταγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const deleteWaste = useMutation({
mutationFn: (id) => client.delete(`/api/waste/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['waste-today'] })
qc.invalidateQueries({ queryKey: ['waste-history'] })
toast.success('Διαγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Αποβλήτα / Φθορές</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
Σήμερα: {todayCount} καταχωρήσεις
{todayCost > 0 && <span style={{ color: '#dc2626', fontWeight: 600 }}> · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} </span>}
</p>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Log form */}
<LogForm products={productsData} onLog={body => logWaste.mutate(body)} isPending={logWaste.isPending} />
{/* Today's entries */}
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
{activeDayId ? 'Σήμερα' : 'Τελευταίες καταχωρήσεις'}
</div>
{todayEntries.length === 0 ? (
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '20px 0' }}>Καμία καταχώρηση ακόμα.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{todayEntries.map(e => (
<WasteRow key={e.id} entry={e} canDelete={!!activeDayId} onDelete={id => deleteWaste.mutate(id)} />
))}
</div>
)}
</div>
{/* History toggle */}
<div style={{ borderTop: '1px solid #f0f0ef', paddingTop: 12 }}>
<button onClick={() => setShowHistory(s => !s)}
style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
{showHistory ? '▾ Απόκρυψη ιστορικού' : '▸ Εμφάνιση ιστορικού'}
</button>
{showHistory && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Από</label>
<input type="date" value={histFrom} onChange={e => setHistFrom(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Έως</label>
<input type="date" value={histTo} onChange={e => setHistTo(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
</div>
<button onClick={() => refetchHist()}
style={{ padding: '5px 14px', borderRadius: 7, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
Αναζήτηση
</button>
</div>
{histEntries.length === 0 ? (
<div style={{ color: '#d1d5db', fontSize: 13, padding: '12px 0' }}>Δεν βρέθηκαν καταχωρήσεις.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{histEntries.map(e => (
<WasteRow key={e.id} entry={e} canDelete={false} onDelete={() => {}} />
))}
<div style={{ fontSize: 12.5, color: '#6b7280', fontWeight: 600, paddingLeft: 4 }}>
{histEntries.length} καταχωρήσεις · εκτιμώμενο κόστος{' '}
<span style={{ color: '#dc2626' }}>
{histEntries.reduce((s, e) => s + (e.total_cost || 0), 0).toLocaleString('el-GR', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -379,6 +379,7 @@ export default function ProductPerformance() {
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
<TH align="right">Κέρδος</TH>
<TH align="right">Απόβλητα</TH>
<TH align="right">% Συνόλου</TH>
<TH align="right">Παραγγελίες</TH>
</THead>
@@ -403,6 +404,14 @@ export default function ProductPerformance() {
</span>
)}
</TD>
<TD mono align="right">
{p.waste_qty > 0 ? (
<span className="text-amber-600">
{p.waste_qty}
{p.waste_cost > 0 && <span className="text-[10px] text-amber-400 ml-1">({fmtEUR(p.waste_cost)})</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>