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:
@@ -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"])
|
||||
|
||||
26
local_backend/models/waste.py
Normal file
26
local_backend/models/waste.py
Normal 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])
|
||||
@@ -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)
|
||||
|
||||
182
local_backend/routers/waste.py
Normal file
182
local_backend/routers/waste.py
Normal 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"]),
|
||||
}
|
||||
37
local_backend/schemas/waste.py
Normal file
37
local_backend/schemas/waste.py
Normal 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}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
|
||||
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