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:
296
manager_dashboard/src/pages/WastePage.jsx
Normal file
296
manager_dashboard/src/pages/WastePage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user