diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index cdb00be..0738fbc 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -1641,3 +1641,98 @@ def cancellations_export( } for c in data["cancellations"]] date_str = (from_dt or "")[:10] return _csv_response(rows, f"cancellations-{date_str}.csv") + + +# --------------------------------------------------------------------------- +# Phase 2L — Discount audit report +# --------------------------------------------------------------------------- + +@router.get("/discounts") +def discounts_report( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + applied_by: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from models.order import OrderDiscount, OrderItem + + q = db.query(OrderDiscount) + if business_day_id: + q = q.join(Order, Order.id == OrderDiscount.order_id).filter(Order.business_day_id == business_day_id) + elif from_dt and to_dt: + q = q.filter( + OrderDiscount.applied_at >= datetime.fromisoformat(from_dt), + OrderDiscount.applied_at <= datetime.fromisoformat(to_dt), + ) + if applied_by: + q = q.filter(OrderDiscount.applied_by == applied_by) + + discounts = q.order_by(OrderDiscount.applied_at.desc()).all() + + users_map = {u.id: u for u in db.query(User).all()} + tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # Preload orders for table names + order_ids = {d.order_id for d in discounts} + orders_map = {} + if order_ids: + for o in db.query(Order).filter(Order.id.in_(order_ids)).all(): + orders_map[o.id] = o + + def _compute_discount_amount(d: OrderDiscount) -> float: + """Compute the actual euro amount discounted.""" + order = orders_map.get(d.order_id) + if d.discount_type == "fixed": + return round(d.discount_value, 2) + if d.discount_type == "percent": + if d.item_id: + item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first() + if item: + base = item.unit_price * item.quantity + return round(base * d.discount_value / 100, 2) + elif order: + base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") + return round(base * d.discount_value / 100, 2) + return 0.0 + + result = [] + total_discount_value = 0.0 + by_waiter: dict = {} + + for d in discounts: + order = orders_map.get(d.order_id) + applier = users_map.get(d.applied_by) + applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}" + table_name = tables_map.get(order.table_id) if order and order.table_id else None + order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None + discount_amount = _compute_discount_amount(d) + total_discount_value += discount_amount + + if d.applied_by not in by_waiter: + by_waiter[d.applied_by] = {"waiter_id": d.applied_by, "waiter_name": applier_name, "count": 0, "total_value": 0.0} + by_waiter[d.applied_by]["count"] += 1 + by_waiter[d.applied_by]["total_value"] = round(by_waiter[d.applied_by]["total_value"] + discount_amount, 2) + + result.append({ + "id": d.id, + "order_id": d.order_id, + "table_name": table_name, + "applied_by_id": d.applied_by, + "applied_by_name": applier_name, + "applied_at": _dt(d.applied_at), + "discount_type": d.discount_type, + "discount_value": d.discount_value, + "discount_amount": discount_amount, + "order_total_before": round(order_total, 2) if order_total is not None else None, + "item_id": d.item_id, + "reason": d.reason, + }) + + return { + "discounts": result, + "total_discount_value": round(total_discount_value, 2), + "order_count": len({d["order_id"] for d in result}), + "by_waiter": sorted(by_waiter.values(), key=lambda x: -x["total_value"]), + } diff --git a/manager_dashboard/src/pages/reports/ReportsPage.jsx b/manager_dashboard/src/pages/reports/ReportsPage.jsx index 87f742b..fba746e 100644 --- a/manager_dashboard/src/pages/reports/ReportsPage.jsx +++ b/manager_dashboard/src/pages/reports/ReportsPage.jsx @@ -22,6 +22,7 @@ import TableAnalytics from './restaurant/TableAnalytics' import PrinterHistory from './operations/PrinterHistory' import PrinterHealthLog from './operations/PrinterHealthLog' import CancellationsLog from './operations/CancellationsLog' +import DiscountsLog from './operations/DiscountsLog' const PARENT_TABS = [ { @@ -58,6 +59,7 @@ const PARENT_TABS = [ { id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory }, { id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog }, { id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog }, + { id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog }, ], }, ] diff --git a/manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx b/manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx new file mode 100644 index 0000000..b2b74fe --- /dev/null +++ b/manager_dashboard/src/pages/reports/operations/DiscountsLog.jsx @@ -0,0 +1,191 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Tag } from 'lucide-react' +import client from '../../../api/client' +import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar' +import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives' +import StatCard from '../shared/StatCard' +import EmptyState from '../shared/EmptyState' +import SkeletonTable from '../shared/SkeletonTable' +import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens' + +function today() { return new Date().toISOString().slice(0, 10) } +function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) } + +export default function DiscountsLog() { + const [mode, setMode] = useState('range') + const [from, setFrom] = useState(monthAgo()) + const [to, setTo] = useState(today()) + const [businessDayId, setBusinessDayId] = useState('all') + const [waiterF, setWaiterF] = useState('all') + + const { data: waitersData } = useQuery({ + queryKey: ['meta-waiters'], + queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data), + staleTime: 5 * 60 * 1000, + }) + const { data: bdData } = useQuery({ + queryKey: ['business-days-list'], + queryFn: () => client.get('/api/reports/business-days').then(r => r.data), + staleTime: 60 * 1000, + }) + + const queryParams = { + ...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}), + ...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}), + ...(waiterF !== 'all' ? { applied_by: waiterF } : {}), + } + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['discounts', mode, from, to, businessDayId, waiterF], + queryFn: () => client.get('/api/reports/discounts', { params: queryParams }).then(r => r.data), + staleTime: 60 * 1000, + }) + + const waiterOptions = [ + { value: 'all', label: 'Όλοι' }, + ...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name }))), + ] + const bdOptions = [ + { value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, + ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` }))), + ] + + const discounts = data?.discounts || [] + const byWaiter = data?.by_waiter || [] + const totalValue = data?.total_discount_value ?? 0 + const orderCount = data?.order_count ?? 0 + + if (isLoading) return