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

@@ -24,6 +24,7 @@ import models.notes # noqa: F401 — registers SiteNote, SiteTodo
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
import models.customers # noqa: F401 — registers Customer
import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
import models.waste # noqa: F401 — registers WasteLog
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -39,6 +40,7 @@ from routers import notes as notes_router
from routers.expenses import contacts_router, expenses_router
from routers import customers as customers_router
from routers import tabs as tabs_router
from routers import waste as waste_router
def _run_migrations():
@@ -277,6 +279,19 @@ def _run_migrations():
# Phase 2B — staff payroll
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
# Phase 2H — void/waste log
"""CREATE TABLE IF NOT EXISTS waste_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity REAL NOT NULL,
unit_cost_snapshot REAL,
total_cost REAL,
reason VARCHAR NOT NULL,
reason_notes TEXT,
logged_by_id INTEGER NOT NULL REFERENCES users(id),
business_day_id INTEGER REFERENCES business_days(id),
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Phase 2G — pay-later tab system
"""CREATE TABLE IF NOT EXISTS tabs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -444,3 +459,4 @@ app.include_router(contacts_router, prefix="/api/contacts", tag
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class WasteLog(Base):
__tablename__ = "waste_log"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Float, nullable=False) # supports decimals
unit_cost_snapshot = Column(Float, nullable=True) # product's effective cost at log time
total_cost = Column(Float, nullable=True) # unit_cost_snapshot * quantity
reason = Column(String, nullable=False) # kitchen_error|dropped|expired|other
reason_notes = Column(Text, nullable=True)
logged_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
logged_at = Column(DateTime(timezone=True), default=_utcnow)
product = relationship("Product", foreign_keys=[product_id])
logged_by = relationship("User", foreign_keys=[logged_by_id])

View File

@@ -904,6 +904,7 @@ def product_performance(
user: User = Depends(require_manager),
):
from models.product import Product
from models.waste import WasteLog
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
@@ -916,6 +917,24 @@ def product_performance(
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
# Phase 2H: load waste for the same period
wq = db.query(WasteLog)
if business_day_id:
wq = wq.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
wq = wq.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
waste_entries = wq.all()
waste_by_product: dict = {}
for w in waste_entries:
pid = w.product_id
if pid not in waste_by_product:
waste_by_product[pid] = {"waste_qty": 0.0, "waste_cost": 0.0}
waste_by_product[pid]["waste_qty"] += w.quantity
waste_by_product[pid]["waste_cost"] += w.total_cost or 0.0
summary: dict = {}
for item in items:
pid = item.product_id
@@ -945,13 +964,31 @@ def product_performance(
summary[pid]["uncosted_revenue"] += revenue
summary[pid]["uncosted_items"] += qty
# Also include products that only have waste (not sold) in the period
for pid, wdata in waste_by_product.items():
if pid not in summary:
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
summary[pid] = {
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0, "revenue": 0.0, "trackable_profit": 0.0,
"uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(),
}
result = []
for entry in summary.values():
pid = entry["product_id"]
wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0})
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["has_gap"] = entry["uncosted_items"] > 0
entry["waste_qty"] = round(wdata["waste_qty"], 3)
entry["waste_cost"] = round(wdata["waste_cost"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)

View File

@@ -0,0 +1,182 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.waste import WasteLog
from models.product import Product
from models.business_day import BusinessDay
from models.user import User
from schemas.waste import WasteLogCreate, WasteLogOut, VALID_REASONS, REASON_LABELS
from routers.deps import require_manager
router = APIRouter()
def _effective_cost(product: Product) -> Optional[float]:
"""Return product's current effective cost (breakdown sum or simple), or None."""
if product.cost_breakdown:
try:
entries = json.loads(product.cost_breakdown)
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
if total > 0:
return total
except Exception:
pass
if product.cost_simple:
return product.cost_simple
return None
def _waste_out(w: WasteLog) -> dict:
product_name = w.product.name if w.product else f"#{w.product_id}"
logger_name = None
if w.logged_by:
logger_name = w.logged_by.full_name or w.logged_by.username
return {
"id": w.id,
"product_id": w.product_id,
"product_name": product_name,
"quantity": w.quantity,
"unit_cost_snapshot": w.unit_cost_snapshot,
"total_cost": w.total_cost,
"reason": w.reason,
"reason_label": REASON_LABELS.get(w.reason, w.reason),
"reason_notes": w.reason_notes,
"logged_by_id": w.logged_by_id,
"logged_by_name": logger_name,
"business_day_id": w.business_day_id,
"logged_at": w.logged_at,
}
@router.get("/")
def list_waste(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog).order_by(WasteLog.logged_at.desc())
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
return {"entries": [_waste_out(w) for w in entries]}
@router.post("/", status_code=status.HTTP_201_CREATED)
def log_waste(
body: WasteLogCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if body.reason not in VALID_REASONS:
raise HTTPException(status_code=400, detail=f"Invalid reason. Valid: {sorted(VALID_REASONS)}")
if body.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be positive")
product = db.query(Product).filter(Product.id == body.product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Snapshot current effective cost
unit_cost = _effective_cost(product)
total_cost = round(unit_cost * body.quantity, 2) if unit_cost is not None else None
# Attach to open business day if any
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
entry = WasteLog(
product_id=body.product_id,
quantity=body.quantity,
reason=body.reason,
reason_notes=body.reason_notes,
unit_cost_snapshot=unit_cost,
total_cost=total_cost,
logged_by_id=user.id,
business_day_id=active_day.id if active_day else None,
)
db.add(entry)
db.commit()
db.refresh(entry)
return _waste_out(entry)
@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waste(
entry_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
entry = db.query(WasteLog).filter(WasteLog.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Waste entry not found")
# Only allow deletion within the same business day
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if active_day and entry.business_day_id != active_day.id:
raise HTTPException(status_code=403, detail="Can only delete waste entries from the current open business day")
db.delete(entry)
db.commit()
@router.get("/summary")
def waste_summary(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog)
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
total_items = len(entries)
total_cost = round(sum(e.total_cost for e in entries if e.total_cost is not None), 2)
uncosted_count = sum(1 for e in entries if e.total_cost is None)
by_reason: dict = {}
by_product: dict = {}
for e in entries:
r = e.reason
if r not in by_reason:
by_reason[r] = {"reason": r, "label": REASON_LABELS.get(r, r), "count": 0, "total_cost": 0.0}
by_reason[r]["count"] += 1
by_reason[r]["total_cost"] += e.total_cost or 0.0
pid = e.product_id
pname = e.product.name if e.product else f"#{pid}"
if pid not in by_product:
by_product[pid] = {"product_id": pid, "product_name": pname, "qty": 0.0, "total_cost": 0.0}
by_product[pid]["qty"] += e.quantity
by_product[pid]["total_cost"] += e.total_cost or 0.0
for v in by_reason.values():
v["total_cost"] = round(v["total_cost"], 2)
for v in by_product.values():
v["total_cost"] = round(v["total_cost"], 2)
return {
"total_items": total_items,
"total_cost": total_cost,
"uncosted_count": uncosted_count,
"by_reason": sorted(by_reason.values(), key=lambda x: -x["count"]),
"by_product": sorted(by_product.values(), key=lambda x: -x["total_cost"]),
}

View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
VALID_REASONS = {"kitchen_error", "dropped", "expired", "other"}
REASON_LABELS = {
"kitchen_error": "Λάθος κουζίνας",
"dropped": "Έπεσε",
"expired": "Έληξε",
"other": "Άλλο",
}
class WasteLogCreate(BaseModel):
product_id: int
quantity: float
reason: str
reason_notes: Optional[str] = None
class WasteLogOut(BaseModel):
id: int
product_id: int
product_name: Optional[str] = None
quantity: float
unit_cost_snapshot: Optional[float] = None
total_cost: Optional[float] = None
reason: str
reason_label: Optional[str] = None
reason_notes: Optional[str] = None
logged_by_id: int
logged_by_name: Optional[str] = None
business_day_id: Optional[int] = None
logged_at: datetime
model_config = {"from_attributes": True}

View File

@@ -16,6 +16,7 @@ import ContactsPage from './pages/ContactsPage'
import ExpensesPage from './pages/ExpensesPage'
import CustomersPage from './pages/CustomersPage'
import TabsPage from './pages/TabsPage'
import WastePage from './pages/WastePage'
import client from './api/client'
function Spinner() {
@@ -91,6 +92,7 @@ export default function App() {
<Route path="expenses" element={<ExpensesPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="tabs" element={<TabsPage />} />
<Route path="waste" element={<WastePage />} />
<Route path="online-orders" element={<OnlineOrdersPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />

View File

@@ -1,6 +1,6 @@
import { NavLink } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard } from 'lucide-react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2 } from 'lucide-react'
import { getIncomingOrders } from '../api/client'
export default function Sidebar() {
@@ -31,6 +31,7 @@ export default function Sidebar() {
{ to: '/contacts', icon: BookUser, label: 'Επαφές' },
{ to: '/customers', icon: Users, label: 'Πελάτες' },
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες' },
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
]

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>