diff --git a/local_backend/main.py b/local_backend/main.py
index 722f131..d05058c 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -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"])
diff --git a/local_backend/models/waste.py b/local_backend/models/waste.py
new file mode 100644
index 0000000..153f5a1
--- /dev/null
+++ b/local_backend/models/waste.py
@@ -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])
diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py
index 97ab09b..cdb00be 100644
--- a/local_backend/routers/reports.py
+++ b/local_backend/routers/reports.py
@@ -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)
diff --git a/local_backend/routers/waste.py b/local_backend/routers/waste.py
new file mode 100644
index 0000000..583a1f9
--- /dev/null
+++ b/local_backend/routers/waste.py
@@ -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"]),
+ }
diff --git a/local_backend/schemas/waste.py b/local_backend/schemas/waste.py
new file mode 100644
index 0000000..a290406
--- /dev/null
+++ b/local_backend/schemas/waste.py
@@ -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}
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
index 2e8ea8a..26764b9 100644
--- a/manager_dashboard/src/App.jsx
+++ b/manager_dashboard/src/App.jsx
@@ -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() {
+ Σήμερα: {todayCount} καταχωρήσεις + {todayCost > 0 && · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €} +
+