From ba075276b1ee79e1f1fd80ae012f0350588213bf Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 13 Jul 2026 10:04:20 +0000 Subject: [PATCH] Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup - Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth) - Removed ?token= from all file/image URLs (browser history exposure) - Added ProtectedRoute wrapper to all capability-gated routes in App.tsx - OCR background task: added transaction boundary, improved error handling and status rollback - DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing - File upload: commit DB row before writing to disk to prevent orphaned files - GP% clamped to 100% in GPReport (credit notes can inflate above 100%) - Added CSV export to GPReport (suppliers, daily data, allowances breakdown) - Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility - DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py - N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N) - Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution - Budget spend rate input: rejects negative values - GPReport allowances toggle: persisted to localStorage across page loads - DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars - Fixed SyntaxWarning from \\d in invoices.py docstring Co-Authored-By: Claude Sonnet 4.6 --- backend/api/backup.py | 20 +- backend/api/ingredients.py | 18 +- backend/api/invoices.py | 87 +- backend/api/purchase_orders.py | 18 +- backend/api/recipes.py | 71 +- backend/database.py | 6 +- frontend/src/App.tsx | 75 +- frontend/src/components/AllowancesReport.tsx | 6 +- frontend/src/components/Budget.tsx | 28 +- frontend/src/components/BulkAllergens.tsx | 876 ++-- frontend/src/components/BulkPublishModal.tsx | 8 +- .../src/components/CostDistributionModal.tsx | 12 +- .../src/components/CreateDisputeModal.tsx | 2 +- frontend/src/components/Dashboard.tsx | 24 +- frontend/src/components/DishEditor.tsx | 4356 ++++++++--------- frontend/src/components/DishList.tsx | 2074 ++++---- .../src/components/DisputeDetailModal.tsx | 16 +- frontend/src/components/Disputes.tsx | 8 +- frontend/src/components/EventOrderEditor.tsx | 1622 +++--- frontend/src/components/EventOrders.tsx | 378 +- frontend/src/components/GPReport.tsx | 104 +- .../src/components/IngredientFlagEditor.tsx | 1784 +++---- frontend/src/components/IngredientModal.tsx | 2550 +++++----- frontend/src/components/Ingredients.tsx | 792 +-- frontend/src/components/InvoiceList.tsx | 6 +- .../src/components/LineItemHistoryModal.tsx | 4 +- frontend/src/components/LinkDisputeModal.tsx | 4 +- frontend/src/components/MapLineItemsModal.tsx | 922 ++-- frontend/src/components/MenuEditor.tsx | 28 +- frontend/src/components/MenuFlagMatrix.tsx | 4 +- frontend/src/components/MenuList.tsx | 14 +- frontend/src/components/PriceImpact.tsx | 508 +- .../src/components/PublishToMenuModal.tsx | 744 +-- frontend/src/components/PurchaseOrderList.tsx | 566 +-- .../src/components/PurchaseOrderModal.tsx | 2014 ++++---- frontend/src/components/Purchases.tsx | 8 +- frontend/src/components/PurchasesReport.tsx | 6 +- frontend/src/components/RecipeEditor.tsx | 3908 +++++++-------- frontend/src/components/RecipeFlagMatrix.tsx | 666 +-- frontend/src/components/RecipeList.tsx | 1456 +++--- .../src/components/ReconcilePurchases.tsx | 1436 +++--- frontend/src/components/Review.tsx | 88 +- frontend/src/components/SalesGPReport.tsx | 896 ++-- frontend/src/components/SearchDefinitions.tsx | 14 +- frontend/src/components/SearchInvoices.tsx | 6 +- frontend/src/components/SearchLineItems.tsx | 18 +- frontend/src/components/Suppliers.tsx | 8 +- frontend/src/components/SupportButton.tsx | 6 +- frontend/src/components/Upload.tsx | 2 +- .../src/components/UsageVarianceReport.tsx | 826 ++-- frontend/src/pages/BookingsStats.tsx | 6 +- frontend/src/pages/NewbookData.tsx | 8 +- frontend/src/pages/ResidentsTableChart.tsx | 2 +- frontend/src/pages/ResosData.tsx | 24 +- frontend/src/pages/Settings.tsx | 244 +- frontend/src/pages/UploadApp.tsx | 1310 ++--- frontend/src/pages/WastageLogbook.tsx | 14 +- 57 files changed, 15427 insertions(+), 15274 deletions(-) diff --git a/backend/api/backup.py b/backend/api/backup.py index 5a1ef74..d9dc89c 100644 --- a/backend/api/backup.py +++ b/backend/api/backup.py @@ -5,7 +5,7 @@ import os import tempfile import shutil from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Query, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -282,15 +282,21 @@ async def delete_backup( @router.get("/{backup_id}/download") async def download_backup( backup_id: int, - token: str, + request: Request, + token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db) ): - """Download a backup file. Auth via token query param for direct browser downloads.""" - from auth import get_current_user, require_cap_from_token - - current_user = await get_current_user_from_token(token, db) + """Download a backup file. Cookie auth preferred; ?token= accepted as fallback.""" + current_user = None + if token: + current_user = await get_current_user_from_token(token, db) if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") + try: + current_user = await get_current_user(request) + except HTTPException: + pass + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin only") diff --git a/backend/api/ingredients.py b/backend/api/ingredients.py index 4501380..b456694 100644 --- a/backend/api/ingredients.py +++ b/backend/api/ingredients.py @@ -6,7 +6,7 @@ from datetime import date, datetime from decimal import Decimal from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, text, and_, or_, delete, update @@ -783,14 +783,20 @@ async def get_ingredient( @router.get("/{ingredient_id}/label-image") async def get_label_image( ingredient_id: int, - token: str, + request: Request, + token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): - """Serve the stored label image for a prepackaged ingredient. - Uses token query param for auth (allows window.open / img src usage).""" + """Serve the stored label image for a prepackaged ingredient. Cookie auth preferred; ?token= accepted as fallback.""" import os - from auth import get_current_user, require_cap_from_token - user = await get_current_user_from_token(token, db) + user = None + if token: + user = await get_current_user_from_token(token, db) + if not user: + try: + user = await get_current_user(request) + except HTTPException: + pass if not user: raise HTTPException(401, "Not authenticated") result = await db.execute( diff --git a/backend/api/invoices.py b/backend/api/invoices.py index edc4879..a36eb8b 100644 --- a/backend/api/invoices.py +++ b/backend/api/invoices.py @@ -118,7 +118,7 @@ def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int return {} -DATA_DIR = "/app/data" +DATA_DIR = os.getenv("DATA_DIR", "/app/data") # Response Models @@ -549,7 +549,11 @@ async def upload_invoice( status=InvoiceStatus.PENDING ) db.add(invoice) - await db.commit() + try: + await db.commit() + except Exception: + os.remove(filepath) + raise await db.refresh(invoice) background_tasks.add_task( @@ -794,19 +798,22 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i except Exception as e: logger.warning(f"Ingredient price auto-update failed (non-critical): {e}") - # Run duplicate detection - detector = DuplicateDetector(db, kitchen_id) - duplicates = await detector.check_duplicates(invoice) + # Run duplicate detection (non-critical — log and continue on failure) + try: + detector = DuplicateDetector(db, kitchen_id) + duplicates = await detector.check_duplicates(invoice) - if duplicates["firm_duplicate"]: - invoice.duplicate_status = "firm_duplicate" - invoice.duplicate_of_id = duplicates["firm_duplicate"].id - elif duplicates["possible_duplicates"]: - invoice.duplicate_status = "possible_duplicate" - invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id + if duplicates["firm_duplicate"]: + invoice.duplicate_status = "firm_duplicate" + invoice.duplicate_of_id = duplicates["firm_duplicate"].id + elif duplicates["possible_duplicates"]: + invoice.duplicate_status = "possible_duplicate" + invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id - if duplicates["related_documents"]: - invoice.related_document_id = duplicates["related_documents"][0].id + if duplicates["related_documents"]: + invoice.related_document_id = duplicates["related_documents"][0].id + except Exception as e: + logger.warning(f"Duplicate detection failed for invoice {invoice_id} (non-critical): {e}") invoice.status = InvoiceStatus.PROCESSED await db.commit() @@ -815,13 +822,17 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i f"duplicate_status={invoice.duplicate_status}") except Exception as e: - logger.error(f"OCR processing error for invoice {invoice_id}: {e}") - stmt = select(Invoice).where(Invoice.id == invoice_id) - db_result = await db.execute(stmt) - invoice = db_result.scalar_one() - invoice.status = InvoiceStatus.PROCESSED - invoice.ocr_raw_text = f"Error: {str(e)}" - await db.commit() + logger.error(f"OCR processing error for invoice {invoice_id}: {e}", exc_info=True) + try: + await db.rollback() + stmt = select(Invoice).where(Invoice.id == invoice_id) + db_result = await db.execute(stmt) + invoice = db_result.scalar_one() + invoice.status = InvoiceStatus.PROCESSED + invoice.ocr_raw_text = f"Error: {str(e)}" + await db.commit() + except Exception as update_err: + logger.error(f"Failed to update invoice {invoice_id} status after OCR error: {update_err}") @router.get("/", response_model=InvoiceListResponse) @@ -2106,19 +2117,26 @@ async def get_invoice_ocr_data( async def get_line_item_preview( invoice_id: int, line_number: int, - token: str, + request: Request, + token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db) ): """Get a cropped image preview of a specific line item from the invoice OCR bounding box.""" import json as json_module import io - from auth import get_current_user, require_cap_from_token from starlette.responses import Response from services.file_archival_service import FileArchivalService - current_user = await get_current_user_from_token(token, db) + current_user = None + if token: + current_user = await get_current_user_from_token(token, db) if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") + try: + current_user = await get_current_user(request) + except HTTPException: + pass + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") invoice = await get_invoice_or_404(invoice_id, current_user, db) @@ -2220,13 +2238,13 @@ async def get_line_item_field_preview( invoice_id: int, line_number: int, field_name: str, - token: str, + request: Request, + token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): """Get a cropped image preview of a specific field within a line item (e.g. product_code).""" import json as json_module import io - from auth import get_current_user, require_cap_from_token from starlette.responses import Response from services.file_archival_service import FileArchivalService @@ -2234,9 +2252,16 @@ async def get_line_item_field_preview( if not azure_key: raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}") - current_user = await get_current_user_from_token(token, db) + current_user = None + if token: + current_user = await get_current_user_from_token(token, db) if not current_user: - raise HTTPException(status_code=401, detail="Invalid token") + try: + current_user = await get_current_user(request) + except HTTPException: + pass + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") invoice = await get_invoice_or_404(invoice_id, current_user, db) @@ -2387,11 +2412,11 @@ async def parse_dates_from_ocr( def _generalize_invoice_number_pattern(sample: str) -> str: - """ + r""" Convert a known invoice number into a regex that matches similar-shaped numbers. Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers. - e.g. 'ID304574' → r'\bID\d{5,7}\b' - 'INV-00123' → r'\bINV-\d{4,6}\b' + e.g. 'ID304574' -> r'\bID\d{5,7}\b' + 'INV-00123' -> r'\bINV-\d{4,6}\b' """ parts = [] i = 0 diff --git a/backend/api/purchase_orders.py b/backend/api/purchase_orders.py index d6275e1..6c5e081 100644 --- a/backend/api/purchase_orders.py +++ b/backend/api/purchase_orders.py @@ -10,7 +10,7 @@ from decimal import Decimal from typing import Optional from html import escape as html_escape -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_, func, delete @@ -661,13 +661,19 @@ def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = @router.get("/{po_id}/preview") async def preview_purchase_order( po_id: int, - token: Optional[str] = None, + request: Request, + token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): - """Return a print-friendly HTML preview of the purchase order (query-param auth).""" - if not token: - raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token") - current_user = await get_current_user_from_token(token, db) + """Return a print-friendly HTML preview of the purchase order. Cookie auth preferred; ?token= accepted as fallback.""" + current_user = None + if token: + current_user = await get_current_user_from_token(token, db) + if not current_user: + try: + current_user = await get_current_user(request) + except HTTPException: + pass if not current_user: raise HTTPException(status_code=401, detail="Not authenticated") po = await _load_po(db, po_id, current_user.kitchen_id) diff --git a/backend/api/recipes.py b/backend/api/recipes.py index 7cb4b42..58ec55c 100644 --- a/backend/api/recipes.py +++ b/backend/api/recipes.py @@ -10,7 +10,7 @@ from decimal import Decimal from typing import Optional from html import escape as html_escape -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, text, and_, delete @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) router = APIRouter() -DATA_DIR = "/app/data" +DATA_DIR = os.getenv("DATA_DIR", "/app/data") # ── Pydantic schemas ───────────────────────────────────────────────────────── @@ -686,16 +686,33 @@ async def list_recipes( result = await db.execute(query.order_by(Recipe.name)) recipes = result.scalars().all() + # Batch-load latest cost snapshot per recipe (1 query vs N) + recipe_ids = [r.id for r in recipes] + snap_map: dict[int, RecipeCostSnapshot] = {} + if recipe_ids: + latest_subq = ( + select( + RecipeCostSnapshot.recipe_id, + func.max(RecipeCostSnapshot.snapshot_date).label("max_date"), + ) + .where(RecipeCostSnapshot.recipe_id.in_(recipe_ids)) + .group_by(RecipeCostSnapshot.recipe_id) + .subquery() + ) + snap_rows = await db.execute( + select(RecipeCostSnapshot).join( + latest_subq, + and_( + RecipeCostSnapshot.recipe_id == latest_subq.c.recipe_id, + RecipeCostSnapshot.snapshot_date == latest_subq.c.max_date, + ), + ) + ) + snap_map = {s.recipe_id: s for s in snap_rows.scalars().all()} + items = [] for r in recipes: - # Get latest cost snapshot - snap_result = await db.execute( - select(RecipeCostSnapshot) - .where(RecipeCostSnapshot.recipe_id == r.id) - .order_by(RecipeCostSnapshot.snapshot_date.desc()) - .limit(1) - ) - snap = snap_result.scalar_one_or_none() + snap = snap_map.get(r.id) # Get flag summary (lightweight) from api.food_flags import compute_recipe_flags @@ -1529,13 +1546,18 @@ async def upload_image( async def serve_image( recipe_id: int, image_id: int, + request: Request, token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): - # img tags can't send Authorization header, so auth via query param - if not token: - raise HTTPException(401, "Not authenticated") - user = await get_current_user_from_token(token, db) + user = None + if token: + user = await get_current_user_from_token(token, db) + if not user: + try: + user = await get_current_user(request) + except HTTPException: + pass if not user: raise HTTPException(401, "Not authenticated") await _get_recipe(recipe_id, user.kitchen_id, db) @@ -1728,6 +1750,8 @@ async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional needed_unit = sr.portions_needed_unit or child_output_unit portions_needed_raw = float(sr.portions_needed) * scale_factor portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit) + if not child_output_qty: + logger.warning(f"Recipe {child.id} ({child.name!r}) has zero yield qty — cost contribution zeroed in parent recipe") scale_ratio = portions_needed / child_output_qty if child_output_qty else 0 cost_contribution = float(child_total) * scale_ratio if child_total else None cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None @@ -2098,14 +2122,19 @@ async def backfill_invoice_references( @router.get("/{recipe_id}/print") async def print_recipe( recipe_id: int, + request: Request, format: str = Query("full"), # "full" | "kitchen" token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): - # window.open() can't send Authorization header, so auth via query param - if not token: - raise HTTPException(status_code=401, detail="Not authenticated") - user = await get_current_user_from_token(token, db) + user = None + if token: + user = await get_current_user_from_token(token, db) + if not user: + try: + user = await get_current_user(request) + except HTTPException: + pass if not user: raise HTTPException(status_code=401, detail="Not authenticated") recipe = await _get_recipe(recipe_id, user.kitchen_id, db) @@ -2116,11 +2145,11 @@ async def print_recipe( from api.food_flags import compute_recipe_flags flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) - html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token) + html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id) return HTMLResponse(content=html) -def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str: +def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0) -> str: """Generate print-optimised HTML for a recipe.""" esc = html_escape name = esc(recipe_data.get("name", "")) @@ -2204,7 +2233,7 @@ def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = if not plating_images: plating_images = recipe_data.get("images", [])[:1] for img in plating_images: - img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}" + img_url = f"/kitchen/api/recipes/{recipe_id}/images/{img['id']}" images_html += f'' time_info = "" diff --git a/backend/database.py b/backend/database.py index 0557af6..85e1f64 100644 --- a/backend/database.py +++ b/backend/database.py @@ -14,9 +14,9 @@ if DATABASE_URL.startswith("postgresql://"): engine = create_async_engine( DATABASE_URL, echo=False, - pool_size=10, # Default is 5 - max_overflow=20, # Default is 10 - allows burst to 30 connections - pool_pre_ping=True # Verify connections are alive before use + pool_size=int(os.getenv("DB_POOL_SIZE", "10")), + max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "20")), + pool_pre_ping=True, ) AsyncSessionLocal = async_sessionmaker( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42e0a23..1d7eb40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,16 @@ +import React from 'react' import { Routes, Route, Navigate } from 'react-router-dom' -import AuthGate from './components/AuthGate' +import AuthGate, { useAuth } from './components/AuthGate' import Layout from './components/Layout' +import { can } from './types' // Re-export so existing components can keep `import { useAuth } from '../App'` -export { useAuth } from './components/AuthGate' +export { useAuth } + +function ProtectedRoute({ cap, element }: { cap: string; element: React.ReactElement }) { + const { user } = useAuth() + return can(user, cap) ? element : +} // Pages import Settings from './pages/Settings' @@ -56,48 +63,48 @@ export default function App() { } /> {/* Invoices */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> {/* Reports */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> {/* Kitchen */} - } /> - } /> - } /> - } /> + } />} /> + } />} /> + } />} /> + } />} /> {/* Bookings */} - } /> - } /> - } /> - } /> + } />} /> + } />} /> + } />} /> + } />} /> {/* Recipes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> {/* Settings */} - } /> + } />} /> } /> diff --git a/frontend/src/components/AllowancesReport.tsx b/frontend/src/components/AllowancesReport.tsx index cfee46e..9231217 100644 --- a/frontend/src/components/AllowancesReport.tsx +++ b/frontend/src/components/AllowancesReport.tsx @@ -255,7 +255,7 @@ export default function AllowancesReport() { queryKey: ['allowances-summary', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/kitchen/api/reports/allowances/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch allowances summary') return res.json() @@ -269,7 +269,7 @@ export default function AllowancesReport() { queryKey: ['allowances-daily', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/kitchen/api/reports/allowances/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch chart data') return res.json() @@ -283,7 +283,7 @@ export default function AllowancesReport() { queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/kitchen/api/reports/disputes/period-summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch disputes summary') return res.json() diff --git a/frontend/src/components/Budget.tsx b/frontend/src/components/Budget.tsx index 1830a16..93e7fe2 100644 --- a/frontend/src/components/Budget.tsx +++ b/frontend/src/components/Budget.tsx @@ -255,12 +255,12 @@ export default function Budget() { queryKey: ['budget', 'weekly', weekOffset], queryFn: async () => { const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch budget data') return res.json() }, - enabled: !!token, + enabled: true, }) // Fetch prior 2 weeks for chart comparison @@ -268,23 +268,23 @@ export default function Budget() { queryKey: ['budget', 'weekly', weekOffset - 1], queryFn: async () => { const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 1}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) return null return res.json() }, - enabled: !!token, + enabled: true, }) const { data: prevWeek2 } = useQuery({ queryKey: ['budget', 'weekly', weekOffset - 2], queryFn: async () => { const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 2}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) return null return res.json() }, - enabled: !!token, + enabled: true, }) const goToPreviousWeek = () => setWeekOffset((prev) => prev - 1) @@ -299,7 +299,7 @@ export default function Budget() { queryKey: ['cover-overrides', 'weekly', weekOffset], queryFn: async () => { const res = await fetch(`/kitchen/api/cover-overrides/weekly?week_offset=${weekOffset}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch override data') return res.json() @@ -313,7 +313,7 @@ export default function Budget() { queryFn: async () => { const res = await fetch( `/kitchen/api/cost-distributions/weekly?week_start=${budgetData!.week_start}&week_end=${budgetData!.week_end}`, - { headers: { Authorization: `Bearer ${token}` } } + { credentials: 'include' } ) if (!res.ok) throw new Error('Failed to fetch distribution data') return res.json() @@ -336,7 +336,7 @@ export default function Budget() { queryFn: async () => { const res = await fetch( `/kitchen/api/resos/resident-covers?start_date=${budgetData!.week_start}&end_date=${budgetData!.week_end}`, - { headers: { Authorization: `Bearer ${token}` } } + { credentials: 'include' } ) if (!res.ok) return {} const data = await res.json() @@ -349,7 +349,7 @@ export default function Budget() { mutationFn: async () => { const res = await fetch('/kitchen/api/cover-overrides/snapshot', { method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ week_offset: weekOffset }), }) if (!res.ok) throw new Error('Failed to create snapshot') @@ -385,7 +385,7 @@ export default function Budget() { const [overrideDate, period] = key.split('|') return fetch('/kitchen/api/cover-overrides', { method: 'PUT', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ override_date: overrideDate, period, override_covers: value }), }) })) @@ -400,7 +400,7 @@ export default function Budget() { const deleteOverride = async (id: number) => { await fetch(`/kitchen/api/cover-overrides/${id}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) refetchOverrides() refetch() @@ -409,7 +409,7 @@ export default function Budget() { const saveSpendRate = async (period: string, food: number | null, drinks: number | null) => { await fetch('/kitchen/api/cover-overrides/spend-rates', { method: 'PUT', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ week_offset: weekOffset, period, food_spend: food, drinks_spend: drinks }), }) refetchOverrides() @@ -806,7 +806,7 @@ export default function Budget() { }} onBlur={(e) => { const inputGross = parseFloat(e.target.value) - if (!isNaN(inputGross) && Math.abs(inputGross - grossVal) > 0.001) { + if (!isNaN(inputGross) && inputGross >= 0 && Math.abs(inputGross - grossVal) > 0.001) { const netVal = Math.round((inputGross / overrideData.vat_rate) * 100) / 100 saveSpendRate(sr.period, netVal, null) } diff --git a/frontend/src/components/BulkAllergens.tsx b/frontend/src/components/BulkAllergens.tsx index e57cd12..2c0e9b6 100644 --- a/frontend/src/components/BulkAllergens.tsx +++ b/frontend/src/components/BulkAllergens.tsx @@ -1,438 +1,438 @@ -import { useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useAuth } from '../App' - -interface FlagInfo { - id: number - food_flag_id: number - flag_name: string - flag_code: string | null - category_name: string - propagation_type: string - source: string -} - -interface AllergenSuggestion { - flag_id: number - flag_name: string - flag_code: string | null - category_name: string - matched_keywords: string[] -} - -interface IngredientItem { - id: number - name: string - category_id: number | null - category_name: string | null - standard_unit: string - notes: string | null - is_prepackaged: boolean - product_ingredients: string | null - flags: FlagInfo[] -} - -interface FoodFlagItem { - id: number - name: string - code: string | null - propagation_type: string -} - -interface FoodFlagCategoryItem { - id: number - name: string - propagation_type: string - required: boolean - flags: FoodFlagItem[] -} - -interface IngredientCategory { - id: number - name: string -} - -export default function BulkAllergens() { - const { token } = useAuth() - const queryClient = useQueryClient() - - const [search, setSearch] = useState('') - const [categoryFilter, setCategoryFilter] = useState('') - const [showUnassessedOnly, setShowUnassessedOnly] = useState(false) - const [expandedId, setExpandedId] = useState(null) - - // Fetch all non-archived ingredients - const { data: ingredients } = useQuery({ - queryKey: ['ingredients-bulk'], - queryFn: async () => { - const res = await fetch('/kitchen/api/ingredients?limit=9999', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to fetch ingredients') - return res.json() - }, - enabled: !!token, - }) - - // Fetch ingredient categories - const { data: categories } = useQuery({ - queryKey: ['ingredient-categories'], - queryFn: async () => { - const res = await fetch('/kitchen/api/ingredients/categories', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) return [] - return res.json() - }, - enabled: !!token, - }) - - // Fetch flag categories (only required ones shown as columns) - const { data: flagCategories } = useQuery({ - queryKey: ['food-flag-categories-full'], - queryFn: async () => { - const res = await fetch('/kitchen/api/food-flags/categories', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to fetch flag categories') - return res.json() - }, - enabled: !!token, - }) - - // Fetch bulk nones (ingredient_id -> category_ids where None is set) - const { data: bulkNones } = useQuery>({ - queryKey: ['bulk-nones'], - queryFn: async () => { - const res = await fetch('/kitchen/api/ingredients/bulk-nones', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) return {} - return res.json() - }, - enabled: !!token, - }) - - // Fetch suggestions for ALL ingredients in bulk (single request) - const { data: allSuggestions } = useQuery>({ - queryKey: ['bulk-suggestions'], - queryFn: async () => { - const res = await fetch('/kitchen/api/food-flags/suggest/bulk', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) return {} - return res.json() - }, - enabled: !!token, - }) - - // Toggle a flag on an ingredient - const toggleFlagMutation = useMutation({ - mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => { - // Get current flags for this ingredient - const ing = ingredients?.find(i => i.id === ingredientId) - const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || [] - - let newFlagIds: number[] - if (action === 'add') { - newFlagIds = [...currentFlagIds, flagId] - } else { - newFlagIds = currentFlagIds.filter(id => id !== flagId) - } - - const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, { - method: 'PUT', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ food_flag_ids: newFlagIds }), - }) - if (!res.ok) throw new Error('Failed to update flags') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) - queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) - }, - }) - - // Toggle None for a category on an ingredient - const toggleNoneMutation = useMutation({ - mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => { - const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ category_id: categoryId }), - }) - if (!res.ok) throw new Error('Failed to toggle none') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) - queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) - }, - }) - - const toggleExpanded = (ing: IngredientItem) => { - setExpandedId(expandedId === ing.id ? null : ing.id) - } - - // Only show required categories as column groups - const requiredCategories = flagCategories?.filter(c => c.required) || [] - - // Build a flat list of flag columns - const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = [] - for (const cat of requiredCategories) { - for (const f of cat.flags) { - flagColumns.push({ - flagId: f.id, - flagName: f.name, - flagCode: f.code, - categoryId: cat.id, - categoryName: cat.name, - propagation: cat.propagation_type, - }) - } - } - - // Filter ingredients - const filtered = (ingredients || []).filter(ing => { - if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false - if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false - if (showUnassessedOnly) { - // Check if ingredient is unassessed for any required category - const nones = bulkNones?.[ing.id] || [] - for (const cat of requiredCategories) { - if (nones.includes(cat.id)) continue // None set for this category - const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id)) - if (!hasFlagInCat) return true // Unassessed for this category - } - return false - } - return true - }) - - return ( -
-

Bulk Allergen Assessment

- - {/* Filters */} -
- setSearch(e.target.value)} - style={styles.searchInput} - /> - - - - {filtered.length} ingredients - -
- - {flagColumns.length === 0 ? ( -
- No required flag categories found. Go to Settings > Food Flags and mark allergen categories as "Required". -
- ) : ( -
- - - - - {requiredCategories.map(cat => ( - - ))} - {flagColumns.map(col => ( - - ))} - - - {filtered.map(ing => { - const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id)) - const nones = bulkNones?.[ing.id] || [] - const isExpanded = expandedId === ing.id - const ingSuggestions = allSuggestions?.[ing.id] - const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id)) - const hasPendingSuggestions = !!pendingSuggestions?.length - const totalCols = 1 + requiredCategories.length + flagColumns.length - - return ( - - - - - {/* None columns per required category */} - {requiredCategories.map(cat => { - const isNone = nones.includes(cat.id) - return ( - - ) - })} - - {/* Flag columns */} - {flagColumns.map(col => { - const isChecked = ingFlagIds.has(col.flagId) - const isNoneForCategory = nones.includes(col.categoryId) - const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId) - return ( - - ) - })} - - - {/* Expanded detail row */} - {isExpanded && ( - - - - )} - - ) - })} -
- Ingredient - - None - - {col.flagCode || col.flagName} -
toggleExpanded(ing)} - title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'} - > - {isExpanded ? '\u25BC' : '\u25B6'} - {ing.name} - {ing.category_name && ( - {ing.category_name} - )} - {ing.is_prepackaged && ( - PKG - )} - - toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })} - disabled={toggleNoneMutation.isPending} - style={{ cursor: 'pointer' }} - title={`None apply for ${cat.name}`} - /> - - toggleFlagMutation.mutate({ - ingredientId: ing.id, - flagId: col.flagId, - action: isChecked ? 'remove' : 'add', - })} - disabled={isNoneForCategory || toggleFlagMutation.isPending} - style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }} - title={col.flagName + (isSuggested ? ' (suggested)' : '')} - /> -
-
- {/* Notes */} -
-
Notes
-
- {ing.notes || 'No notes'} -
-
- - {/* Product ingredients */} -
-
- Label Ingredients {ing.is_prepackaged && (prepackaged)} -
-
- {ing.product_ingredients || 'Not available'} -
-
- - {/* Allergen suggestions */} -
-
Keyword Suggestions
- {!pendingSuggestions?.length ? ( -
No suggestions
- ) : ( -
- {pendingSuggestions.map(s => ( -
- - {s.flag_name} - - {s.matched_keywords.join(', ')} - - - -
- ))} -
- )} -
-
-
-
- )} -
- ) -} - -const styles: Record = { - page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' }, - filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const }, - searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' }, - select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' }, - emptyState: { padding: '3rem', textAlign: 'center' as const, color: '#888', background: '#fafafa', borderRadius: '8px' }, - table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }, - th: { padding: '0.5rem 0.4rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555' }, - tr: { borderBottom: '1px solid #f0f0f0' }, - td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' }, -} +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useAuth } from '../App' + +interface FlagInfo { + id: number + food_flag_id: number + flag_name: string + flag_code: string | null + category_name: string + propagation_type: string + source: string +} + +interface AllergenSuggestion { + flag_id: number + flag_name: string + flag_code: string | null + category_name: string + matched_keywords: string[] +} + +interface IngredientItem { + id: number + name: string + category_id: number | null + category_name: string | null + standard_unit: string + notes: string | null + is_prepackaged: boolean + product_ingredients: string | null + flags: FlagInfo[] +} + +interface FoodFlagItem { + id: number + name: string + code: string | null + propagation_type: string +} + +interface FoodFlagCategoryItem { + id: number + name: string + propagation_type: string + required: boolean + flags: FoodFlagItem[] +} + +interface IngredientCategory { + id: number + name: string +} + +export default function BulkAllergens() { + const { token } = useAuth() + const queryClient = useQueryClient() + + const [search, setSearch] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [showUnassessedOnly, setShowUnassessedOnly] = useState(false) + const [expandedId, setExpandedId] = useState(null) + + // Fetch all non-archived ingredients + const { data: ingredients } = useQuery({ + queryKey: ['ingredients-bulk'], + queryFn: async () => { + const res = await fetch('/kitchen/api/ingredients?limit=9999', { + credentials: 'include', + }) + if (!res.ok) throw new Error('Failed to fetch ingredients') + return res.json() + }, + enabled: true, + }) + + // Fetch ingredient categories + const { data: categories } = useQuery({ + queryKey: ['ingredient-categories'], + queryFn: async () => { + const res = await fetch('/kitchen/api/ingredients/categories', { + credentials: 'include', + }) + if (!res.ok) return [] + return res.json() + }, + enabled: true, + }) + + // Fetch flag categories (only required ones shown as columns) + const { data: flagCategories } = useQuery({ + queryKey: ['food-flag-categories-full'], + queryFn: async () => { + const res = await fetch('/kitchen/api/food-flags/categories', { + credentials: 'include', + }) + if (!res.ok) throw new Error('Failed to fetch flag categories') + return res.json() + }, + enabled: true, + }) + + // Fetch bulk nones (ingredient_id -> category_ids where None is set) + const { data: bulkNones } = useQuery>({ + queryKey: ['bulk-nones'], + queryFn: async () => { + const res = await fetch('/kitchen/api/ingredients/bulk-nones', { + credentials: 'include', + }) + if (!res.ok) return {} + return res.json() + }, + enabled: true, + }) + + // Fetch suggestions for ALL ingredients in bulk (single request) + const { data: allSuggestions } = useQuery>({ + queryKey: ['bulk-suggestions'], + queryFn: async () => { + const res = await fetch('/kitchen/api/food-flags/suggest/bulk', { + credentials: 'include', + }) + if (!res.ok) return {} + return res.json() + }, + enabled: true, + }) + + // Toggle a flag on an ingredient + const toggleFlagMutation = useMutation({ + mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => { + // Get current flags for this ingredient + const ing = ingredients?.find(i => i.id === ingredientId) + const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || [] + + let newFlagIds: number[] + if (action === 'add') { + newFlagIds = [...currentFlagIds, flagId] + } else { + newFlagIds = currentFlagIds.filter(id => id !== flagId) + } + + const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, { + method: 'PUT', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ food_flag_ids: newFlagIds }), + }) + if (!res.ok) throw new Error('Failed to update flags') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) + queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) + }, + }) + + // Toggle None for a category on an ingredient + const toggleNoneMutation = useMutation({ + mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => { + const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, { + method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category_id: categoryId }), + }) + if (!res.ok) throw new Error('Failed to toggle none') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) + queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) + }, + }) + + const toggleExpanded = (ing: IngredientItem) => { + setExpandedId(expandedId === ing.id ? null : ing.id) + } + + // Only show required categories as column groups + const requiredCategories = flagCategories?.filter(c => c.required) || [] + + // Build a flat list of flag columns + const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = [] + for (const cat of requiredCategories) { + for (const f of cat.flags) { + flagColumns.push({ + flagId: f.id, + flagName: f.name, + flagCode: f.code, + categoryId: cat.id, + categoryName: cat.name, + propagation: cat.propagation_type, + }) + } + } + + // Filter ingredients + const filtered = (ingredients || []).filter(ing => { + if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false + if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false + if (showUnassessedOnly) { + // Check if ingredient is unassessed for any required category + const nones = bulkNones?.[ing.id] || [] + for (const cat of requiredCategories) { + if (nones.includes(cat.id)) continue // None set for this category + const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id)) + if (!hasFlagInCat) return true // Unassessed for this category + } + return false + } + return true + }) + + return ( +
+

Bulk Allergen Assessment

+ + {/* Filters */} +
+ setSearch(e.target.value)} + style={styles.searchInput} + /> + + + + {filtered.length} ingredients + +
+ + {flagColumns.length === 0 ? ( +
+ No required flag categories found. Go to Settings > Food Flags and mark allergen categories as "Required". +
+ ) : ( +
+ + + + + {requiredCategories.map(cat => ( + + ))} + {flagColumns.map(col => ( + + ))} + + + {filtered.map(ing => { + const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id)) + const nones = bulkNones?.[ing.id] || [] + const isExpanded = expandedId === ing.id + const ingSuggestions = allSuggestions?.[ing.id] + const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id)) + const hasPendingSuggestions = !!pendingSuggestions?.length + const totalCols = 1 + requiredCategories.length + flagColumns.length + + return ( + + + + + {/* None columns per required category */} + {requiredCategories.map(cat => { + const isNone = nones.includes(cat.id) + return ( + + ) + })} + + {/* Flag columns */} + {flagColumns.map(col => { + const isChecked = ingFlagIds.has(col.flagId) + const isNoneForCategory = nones.includes(col.categoryId) + const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId) + return ( + + ) + })} + + + {/* Expanded detail row */} + {isExpanded && ( + + + + )} + + ) + })} +
+ Ingredient + + None + + {col.flagCode || col.flagName} +
toggleExpanded(ing)} + title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'} + > + {isExpanded ? '\u25BC' : '\u25B6'} + {ing.name} + {ing.category_name && ( + {ing.category_name} + )} + {ing.is_prepackaged && ( + PKG + )} + + toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })} + disabled={toggleNoneMutation.isPending} + style={{ cursor: 'pointer' }} + title={`None apply for ${cat.name}`} + /> + + toggleFlagMutation.mutate({ + ingredientId: ing.id, + flagId: col.flagId, + action: isChecked ? 'remove' : 'add', + })} + disabled={isNoneForCategory || toggleFlagMutation.isPending} + style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }} + title={col.flagName + (isSuggested ? ' (suggested)' : '')} + /> +
+
+ {/* Notes */} +
+
Notes
+
+ {ing.notes || 'No notes'} +
+
+ + {/* Product ingredients */} +
+
+ Label Ingredients {ing.is_prepackaged && (prepackaged)} +
+
+ {ing.product_ingredients || 'Not available'} +
+
+ + {/* Allergen suggestions */} +
+
Keyword Suggestions
+ {!pendingSuggestions?.length ? ( +
No suggestions
+ ) : ( +
+ {pendingSuggestions.map(s => ( +
+ + {s.flag_name} + + {s.matched_keywords.join(', ')} + + + +
+ ))} +
+ )} +
+
+
+
+ )} +
+ ) +} + +const styles: Record = { + page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' }, + filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const }, + searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' }, + select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' }, + emptyState: { padding: '3rem', textAlign: 'center' as const, color: '#888', background: '#fafafa', borderRadius: '8px' }, + table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }, + th: { padding: '0.5rem 0.4rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555' }, + tr: { borderBottom: '1px solid #f0f0f0' }, + td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' }, +} diff --git a/frontend/src/components/BulkPublishModal.tsx b/frontend/src/components/BulkPublishModal.tsx index 32fb654..c8ae1d6 100644 --- a/frontend/src/components/BulkPublishModal.tsx +++ b/frontend/src/components/BulkPublishModal.tsx @@ -32,12 +32,12 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC queryKey: ['dishes-for-bulk'], queryFn: async () => { const res = await fetch('/kitchen/api/recipes?recipe_type=dish', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed') return res.json() }, - enabled: !!token, + enabled: true, }) // Check flags for selected dishes @@ -47,7 +47,7 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC const results: Record }> = {} for (const rid of selected) { const res = await fetch(`/kitchen/api/food-flags/recipes/${rid}/flags`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (res.ok) { const data = await res.json() @@ -78,7 +78,7 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC }) const res = await fetch(`/kitchen/api/menus/${menuId}/items/bulk`, { method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ division_id: divisionId, confirmed_by_name: confirmedBy.trim(), diff --git a/frontend/src/components/CostDistributionModal.tsx b/frontend/src/components/CostDistributionModal.tsx index 990f217..735cc72 100644 --- a/frontend/src/components/CostDistributionModal.tsx +++ b/frontend/src/components/CostDistributionModal.tsx @@ -124,7 +124,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic // Load existing distribution setLoading(true) fetch(`/kitchen/api/cost-distributions/${distributionId}`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) .then(r => { if (!r.ok) throw new Error('Failed to load distribution'); return r.json() }) .then((data: DistributionDetail) => { @@ -137,7 +137,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic // Load invoice availability setLoading(true) fetch(`/kitchen/api/cost-distributions/invoice/${invoiceId}/availability`, { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) .then(r => { if (!r.ok) throw new Error('Failed to load invoice data'); return r.json() }) .then((data: InvoiceAvailability) => { @@ -278,7 +278,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic const res = await fetch('/kitchen/api/cost-distributions/', { method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) @@ -302,7 +302,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic try { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { method: 'PUT', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes }), }) if (!res.ok) throw new Error('Failed to update') @@ -322,7 +322,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic try { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) { const data = await res.json() @@ -345,7 +345,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount) const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, { method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { diff --git a/frontend/src/components/CreateDisputeModal.tsx b/frontend/src/components/CreateDisputeModal.tsx index 4d76098..dfd27e1 100644 --- a/frontend/src/components/CreateDisputeModal.tsx +++ b/frontend/src/components/CreateDisputeModal.tsx @@ -71,9 +71,9 @@ export default function CreateDisputeModal({ mutationFn: async (data: CreateDisputeRequest) => { const res = await fetch('/kitchen/api/disputes', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 21b6553..2786478 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -137,19 +137,19 @@ export default function Dashboard() { queryKey: ['resos-settings'], queryFn: async () => { const res = await fetch('/kitchen/api/resos/settings', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch Resos settings') return res.json() }, - enabled: !!token, + enabled: true, }) const { data, isLoading, error } = useQuery({ queryKey: ['dashboard'], queryFn: async () => { const res = await fetch('/kitchen/api/reports/dashboard', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch dashboard') return res.json() @@ -160,7 +160,7 @@ export default function Dashboard() { queryKey: ['resos-dashboard-covers'], queryFn: async () => { const res = await fetch('/kitchen/api/resos/dashboard/today-tomorrow', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch Resos covers') const data = await res.json() @@ -173,12 +173,12 @@ export default function Dashboard() { queryKey: ['newbook-arrival-stats'], queryFn: async () => { const res = await fetch('/kitchen/api/newbook/dashboard/arrivals?days=3', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch arrival stats') return res.json() }, - enabled: !!token, + enabled: true, staleTime: 5 * 60 * 1000, // Cache for 5 minutes }) @@ -194,12 +194,12 @@ export default function Dashboard() { queryKey: ['upcoming-events'], queryFn: async () => { const res = await fetch('/kitchen/api/calendar-events/dashboard/upcoming', { - headers: { Authorization: `Bearer ${token}` } + credentials: 'include' }) if (!res.ok) throw new Error('Failed to fetch upcoming events') return res.json() }, - enabled: !!token, + enabled: true, staleTime: 5 * 60 * 1000 // Cache for 5 minutes }) @@ -218,12 +218,12 @@ export default function Dashboard() { queryKey: ['recipe-dashboard-stats'], queryFn: async () => { const res = await fetch('/kitchen/api/recipes/dashboard-stats', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch recipe stats') return res.json() }, - enabled: !!token, + enabled: true, staleTime: 5 * 60 * 1000, }) @@ -231,12 +231,12 @@ export default function Dashboard() { queryKey: ['dispute-stats'], queryFn: async () => { const res = await fetch('/kitchen/api/disputes/stats/summary', { - headers: { Authorization: `Bearer ${token}` } + credentials: 'include' }) if (!res.ok) throw new Error('Failed to fetch dispute stats') return res.json() }, - enabled: !!token, + enabled: true, staleTime: 5 * 60 * 1000 // Cache for 5 minutes }) diff --git a/frontend/src/components/DishEditor.tsx b/frontend/src/components/DishEditor.tsx index c93d529..1cb1674 100644 --- a/frontend/src/components/DishEditor.tsx +++ b/frontend/src/components/DishEditor.tsx @@ -1,2178 +1,2178 @@ -import { useState, useEffect } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useParams, useNavigate } from 'react-router-dom' -import { useAuth } from '../App' -import FoodFlagBadges from './FoodFlagBadges' -import RecipeFlagMatrix from './RecipeFlagMatrix' -import IngredientModal from './IngredientModal' -import PublishToMenuModal from './PublishToMenuModal' -import { EditingIngredient, IngredientModalResult } from '../utils/ingredientHelpers' - -interface RecipeDetail { - id: number - name: string - recipe_type: string - menu_section_id: number | null - menu_section_name: string | null - description: string | null - batch_portions: number - prep_time_minutes: number | null - cook_time_minutes: number | null - notes: string | null - is_archived: boolean - kds_menu_item_name: string | null - sambapos_portion_name: string | null - gross_sell_price: number | null - ingredients: RecipeIngredientItem[] - sub_recipes: SubRecipeItem[] - steps: StepItem[] - images: ImageItem[] - created_at: string - updated_at: string -} - -interface RecipeIngredientItem { - id: number - ingredient_id: number - ingredient_name: string - quantity: number - unit: string - standard_unit: string - compatible_units: string[] - yield_percent: number - effective_price: number | null - cost: number | null - is_manual_price?: boolean - has_no_price?: boolean - notes: string | null - sort_order: number -} - -interface SubRecipeItem { - id: number - child_recipe_id: number - child_recipe_name: string - child_recipe_type: string - batch_portions: number - batch_output_type: string - batch_yield_qty: number | null - batch_yield_unit: string | null - output_qty: number - output_unit: string - portions_needed: number - portions_needed_unit: string - compatible_units: string[] - cost_per_portion: number | null - cost_contribution: number | null - has_manual_price_ingredients?: boolean - has_no_price_ingredients?: boolean - notes: string | null - sort_order: number -} - -interface StepItem { - id: number - step_number: number - title: string | null - instruction: string - image_path: string | null - duration_minutes: number | null - notes: string | null -} - -interface ImageItem { - id: number - image_path: string - caption: string | null - image_type: string - sort_order: number -} - -interface CostIngredient { - ingredient_id: number - ingredient_name: string - quantity: number - unit: string - yield_percent: number - cost_recent: number | null - cost_min: number | null - cost_max: number | null - is_manual_price?: boolean - has_no_price?: boolean -} - -interface CostSubRecipe { - child_recipe_id: number - child_recipe_name: string - batch_output_type: string - output_qty: number - output_unit: string - portions_needed: number - cost_contribution: number | null - child_ingredients?: CostIngredient[] - child_sub_recipes?: CostSubRecipe[] -} - -interface CostData { - recipe_id: number - batch_portions: number - batch_output_type: string - output_qty: number - output_unit: string - ingredients: CostIngredient[] - sub_recipes: CostSubRecipe[] - total_cost_recent: number | null - total_cost_min: number | null - total_cost_max: number | null - cost_per_portion: number | null - gp_comparison: Array<{ gp_target: number; suggested_price: number }> | null -} - -interface FlagState { - flags: Array<{ - food_flag_id: number - flag_name: string - flag_code: string | null - flag_icon: string | null - category_id: number - category_name: string - propagation_type: string - source_type: string - is_active: boolean - excludable_on_request: boolean - source_ingredients: string[] - }> - unassessed_ingredients: Array<{ id: number; name: string }> - open_suggestion_ingredients?: Array<{ ingredient_id: number; ingredient_name: string; suggestion_count: number }> - recipe_text_suggestions?: Array<{ - flag_id: number - flag_name: string - flag_code: string | null - category_name: string - matched_keywords: string[] - sources: string[] - }> -} - -interface CostTrendSnapshot { - id: number - cost_per_portion: number - total_cost: number - trigger: string - created_at: string - changes: string[] -} - -interface CostTrendResponse { - snapshots: CostTrendSnapshot[] -} - -interface ChangeLogEntry { - id: number - change_summary: string - username: string - created_at: string -} - -interface IngredientSuggestion { - id: number - name: string - standard_unit: string - similarity: number -} - -interface MenuSection { - id: number - name: string -} - -const COMPATIBLE_UNITS: Record = { - g: ['g', 'kg'], kg: ['g', 'kg'], - ml: ['ml', 'ltr'], ltr: ['ml', 'ltr'], - each: ['each'], portion: ['portion'], -} -function getCompatibleUnits(unit: string): string[] { - return COMPATIBLE_UNITS[unit] || [unit] -} - -export default function DishEditor() { - const { id } = useParams<{ id: string }>() - const recipeId = parseInt(id || '0') - const { token, user } = useAuth() - const navigate = useNavigate() - const queryClient = useQueryClient() - - // Edit states - const [editName, setEditName] = useState('') - const [editDesc, setEditDesc] = useState('') - const [editPrep, setEditPrep] = useState('') - const [editCook, setEditCook] = useState('') - const [editNotes, setEditNotes] = useState('') - const [editSection, setEditSection] = useState('') - const [editKds, setEditKds] = useState('') - const [isDirty, setIsDirty] = useState(false) - - // Add ingredient modal - const [showAddIng, setShowAddIng] = useState(false) - const [ingSearch, setIngSearch] = useState('') - const [ingSuggestions, setIngSuggestions] = useState([]) - const [selectedIngId, setSelectedIngId] = useState(null) - const [selectedIngUnit, setSelectedIngUnit] = useState('') - const [selectedIngCompatUnits, setSelectedIngCompatUnits] = useState([]) - const [ingQty, setIngQty] = useState('') - const [ingNotes, setIngNotes] = useState('') - - // Create ingredient sub-modal - const [showCreateIng, setShowCreateIng] = useState(false) - - // Edit ingredient modal (from open suggestion click) - const [editIngId, setEditIngId] = useState(null) - - // Inline editing ingredient rows - const [editingRiId, setEditingRiId] = useState(null) - const [editRiQty, setEditRiQty] = useState('') - const [editRiUnit, setEditRiUnit] = useState('') - const [editRiYield, setEditRiYield] = useState('100') - const [editRiNotes, setEditRiNotes] = useState('') - - // Drag-and-drop sort mode - const [sortingIngredients, setSortingIngredients] = useState(false) - const [sortingSubs, setSortingSubs] = useState(false) - const [dragIdx, setDragIdx] = useState(null) - const [dragOverIdx, setDragOverIdx] = useState(null) - const [dragType, setDragType] = useState<'ing' | 'sub' | null>(null) - - // Add sub-recipe modal - const [showAddSub, setShowAddSub] = useState(false) - const [selectedSubId, setSelectedSubId] = useState(null) - const [subPortions, setSubPortions] = useState('') - const [subUnit, setSubUnit] = useState('') - - // Add/Edit step - const [showAddStep, setShowAddStep] = useState(false) - const [stepTitle, setStepTitle] = useState('') - const [stepInstruction, setStepInstruction] = useState('') - const [stepDuration, setStepDuration] = useState('') - const [editingStepId, setEditingStepId] = useState(null) - - // Scale - const [scalePortions, setScalePortions] = useState('') - - // Image upload - const [showImageUpload, setShowImageUpload] = useState(false) - const [imageCaption, setImageCaption] = useState('') - const [imageFile, setImageFile] = useState(null) - - // Gross price GP calculator - const [grossPrice, setGrossPrice] = useState('') - - // SambaPOS item picker - const [showSambaposPicker, setShowSambaposPicker] = useState(false) - const [sambaposSearch, setSambaposSearch] = useState('') - - // Publish to menu - const [showPublishModal, setShowPublishModal] = useState(false) - - // Cost trend - const [showCostTrend, setShowCostTrend] = useState(false) - const [trendTooltip, setTrendTooltip] = useState<{ x: number; y: number; date: string; cost: string; trigger: string } | null>(null) - - // Image lightbox - const [lightboxImg, setLightboxImg] = useState(null) - - // Sections — showMatrix stores category_id or null - const [showMatrix, setShowMatrix] = useState(null) - const [showHistory, setShowHistory] = useState(false) - - // Fetch recipe - const { data: recipe } = useQuery({ - queryKey: ['recipe', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Not found') - return res.json() - }, - enabled: !!token && !!recipeId, - }) - - // Fetch sections (dish courses) - const { data: sections } = useQuery({ - queryKey: ['dish-courses'], - queryFn: async () => { - const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=dish', { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token, - }) - - // Fetch costing (base, unscaled) - const { data: costData } = useQuery({ - queryKey: ['recipe-cost', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/costing`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token && !!recipeId, - }) - - // Fetch costing (scaled) - const { data: scaledCostData } = useQuery({ - queryKey: ['recipe-cost-scaled', recipeId, scalePortions], - queryFn: async () => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/costing?scale_to=${scalePortions}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token && !!recipeId && !!scalePortions && parseInt(scalePortions) > 0, - }) - - // Fetch flags - const { data: flagData } = useQuery({ - queryKey: ['recipe-flags', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token && !!recipeId, - }) - - // Fetch all flag categories (for matrix buttons regardless of active flags) - const { data: flagCategories } = useQuery>({ - queryKey: ['food-flag-categories'], - queryFn: async () => { - const res = await fetch('/kitchen/api/food-flags/categories', { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token, - }) - - // Fetch change log - const { data: changeLog } = useQuery({ - queryKey: ['recipe-changelog', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/change-log`, { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token && !!recipeId && showHistory, - }) - - // Fetch cost trend - const { data: costTrendRaw } = useQuery({ - queryKey: ['recipe-cost-trend', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/cost-trend`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to fetch cost trend') - return res.json() - }, - enabled: !!token && !!recipeId && showCostTrend, - }) - - // Fetch menus this dish is published on - const { data: dishMenus } = useQuery>({ - queryKey: ['dish-menus', recipeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/menus/dish/${recipeId}/menus`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) return [] - return res.json() - }, - enabled: !!token && !!recipeId, - }) - - // Fetch ingredient detail for edit modal - const { data: editIngData } = useQuery({ - queryKey: ['ingredient-edit', editIngId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/ingredients/${editIngId}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Not found') - const data = await res.json() - return { - id: data.id, - name: data.name, - category_id: data.category_id, - standard_unit: data.standard_unit, - yield_percent: Number(data.yield_percent), - manual_price: data.manual_price != null ? Number(data.manual_price) : null, - notes: data.notes, - is_prepackaged: data.is_prepackaged || false, - is_free: data.is_free || false, - product_ingredients: data.product_ingredients, - has_label_image: data.has_label_image || false, - } - }, - enabled: !!token && !!editIngId, - }) - - // Fetch available recipes for sub-recipe dropdown (component recipes only) - const { data: availableRecipes } = useQuery>({ - queryKey: ['recipes-list-for-sub'], - queryFn: async () => { - const res = await fetch('/kitchen/api/recipes?recipe_type=component', { - headers: { Authorization: `Bearer ${token}` }, - }) - return res.json() - }, - enabled: !!token && showAddSub, - }) - - // SambaPOS menu items with portions (for picker modal) - interface SambaposMenuItem { menu_item_name: string; portion_name: string; category: string; on_pos_menu: boolean } - const { data: sambaposItems } = useQuery({ - queryKey: ['sambapos-menu-items-portions'], - queryFn: async () => { - const res = await fetch('/kitchen/api/sambapos/menu-items-with-portions', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) return [] - return res.json() - }, - enabled: !!token && showSambaposPicker, - }) - - // Init form from recipe - useEffect(() => { - if (recipe) { - setEditName(recipe.name) - setEditDesc(recipe.description || '') - setEditPrep(recipe.prep_time_minutes?.toString() || '') - setEditCook(recipe.cook_time_minutes?.toString() || '') - setEditNotes(recipe.notes || '') - setEditSection(recipe.menu_section_id?.toString() || '') - setEditKds(recipe.kds_menu_item_name || '') - setGrossPrice(recipe.gross_sell_price?.toString() || '') - setIsDirty(false) - } - }, [recipe]) - - // Search ingredients - useEffect(() => { - if (!ingSearch || ingSearch.length < 2 || !token) return - const timer = setTimeout(async () => { - try { - const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(ingSearch)}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (res.ok) { - const data = await res.json() - setIngSuggestions(Array.isArray(data) ? data : data.suggestions || []) - } else { - console.warn('Ingredient suggest failed:', res.status, await res.text().catch(() => '')) - } - } catch (err) { console.warn('Ingredient suggest error:', err) } - }, 300) - return () => clearTimeout(timer) - }, [ingSearch, token]) - - // Mutations - const updateMutation = useMutation({ - mutationFn: async (data: Record) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Failed to update') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - setIsDirty(false) - }, - }) - - const addIngMutation = useMutation({ - mutationFn: async (data: { ingredient_id: number; quantity: number; unit?: string; notes?: string }) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/ingredients`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Failed to add') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] }) - setShowAddIng(false) - setIngSearch('') - setSelectedIngId(null) - setSelectedIngUnit('') - setSelectedIngCompatUnits([]) - setIngQty('') - setIngNotes('') - }, - }) - - const updateIngMutation = useMutation({ - mutationFn: async ({ riId, quantity, unit, yield_percent, notes }: { riId: number; quantity?: number; unit?: string; yield_percent?: number; notes?: string }) => { - const res = await fetch(`/kitchen/api/recipes/recipe-ingredients/${riId}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ quantity, unit, yield_percent, notes }), - }) - if (!res.ok) throw new Error('Failed to update') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - setEditingRiId(null) - }, - }) - - const removeIngMutation = useMutation({ - mutationFn: async (riId: number) => { - const res = await fetch(`/kitchen/api/recipes/recipe-ingredients/${riId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to remove') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] }) - }, - }) - - const addSubMutation = useMutation({ - mutationFn: async (data: { child_recipe_id: number; portions_needed: number; portions_needed_unit?: string }) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/sub-recipes`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.detail || 'Failed to add sub-recipe') - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - setShowAddSub(false) - }, - }) - - const removeSubMutation = useMutation({ - mutationFn: async (srId: number) => { - const res = await fetch(`/kitchen/api/recipes/recipe-sub-recipes/${srId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-cost-trend', recipeId] }) - queryClient.invalidateQueries({ queryKey: ['recipe-changelog', recipeId] }) - }, - }) - - const addStepMutation = useMutation({ - mutationFn: async (data: { title?: string; instruction: string; step_number: number; duration_minutes?: number }) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/steps`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Failed') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - setShowAddStep(false) - setStepTitle('') - setStepInstruction('') - setStepDuration('') - }, - }) - - const updateStepMutation = useMutation({ - mutationFn: async ({ stepId, data }: { stepId: number; data: { title?: string; instruction?: string; duration_minutes?: number } }) => { - const res = await fetch(`/kitchen/api/recipes/recipe-steps/${stepId}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) throw new Error('Failed') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - setEditingStepId(null) - setShowAddStep(false) - setStepTitle('') - setStepInstruction('') - setStepDuration('') - }, - }) - - const removeStepMutation = useMutation({ - mutationFn: async (stepId: number) => { - const res = await fetch(`/kitchen/api/recipes/recipe-steps/${stepId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed') - }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }), - }) - - // Image upload mutation - const uploadImageMutation = useMutation({ - mutationFn: async ({ file, caption, image_type }: { file: File; caption: string; image_type: string }) => { - const formData = new FormData() - formData.append('file', file) - formData.append('caption', caption) - formData.append('image_type', image_type) - const res = await fetch(`/kitchen/api/recipes/${recipeId}/images`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: formData, - }) - if (!res.ok) throw new Error('Failed to upload image') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - setShowImageUpload(false) - setImageCaption('') - setImageFile(null) - }, - }) - - // Delete image mutation - const deleteImageMutation = useMutation({ - mutationFn: async (imageId: number) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/images/${imageId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to delete image') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - }, - }) - - // Batch reorder ingredients - const reorderIngMutation = useMutation({ - mutationFn: async (ingredientIds: number[]) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/ingredients/reorder`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ ingredient_ids: ingredientIds }), - }) - if (!res.ok) throw new Error('Failed to reorder') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - }, - }) - - // Batch reorder sub-recipes - const reorderSubMutation = useMutation({ - mutationFn: async (subRecipeIds: number[]) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/sub-recipes/reorder`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ sub_recipe_ids: subRecipeIds }), - }) - if (!res.ok) throw new Error('Failed to reorder') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - }, - }) - - // Reorder steps mutation - const reorderStepsMutation = useMutation({ - mutationFn: async (stepIds: number[]) => { - const res = await fetch(`/kitchen/api/recipes/${recipeId}/steps/reorder`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ step_ids: stepIds }), - }) - if (!res.ok) throw new Error('Failed to reorder steps') - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] }) - }, - }) - - // Drag-and-drop handlers - const handleDragStart = (index: number, type: 'ing' | 'sub') => { - setDragIdx(index) - setDragType(type) - } - - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault() - setDragOverIdx(index) - } - - const handleDropIngredients = () => { - if (dragIdx === null || dragOverIdx === null || !recipe || dragType !== 'ing') { - setDragIdx(null) - setDragOverIdx(null) - setDragType(null) - return - } - const sorted = [...recipe.ingredients].sort((a, b) => a.sort_order - b.sort_order) - const items = [...sorted] - const [moved] = items.splice(dragIdx, 1) - items.splice(dragOverIdx, 0, moved) - reorderIngMutation.mutate(items.map(i => i.id)) - setDragIdx(null) - setDragOverIdx(null) - setDragType(null) - } - - const handleDropSubs = () => { - if (dragIdx === null || dragOverIdx === null || !recipe || dragType !== 'sub') { - setDragIdx(null) - setDragOverIdx(null) - setDragType(null) - return - } - const items = [...recipe.sub_recipes] - const [moved] = items.splice(dragIdx, 1) - items.splice(dragOverIdx, 0, moved) - reorderSubMutation.mutate(items.map(i => i.id)) - setDragIdx(null) - setDragOverIdx(null) - setDragType(null) - } - - // Step reorder helpers - const handleMoveStep = (index: number, direction: 'up' | 'down') => { - if (!recipe) return - const steps = [...recipe.steps] - const swapIndex = direction === 'up' ? index - 1 : index + 1 - if (swapIndex < 0 || swapIndex >= steps.length) return - const newSteps = [...steps] - const temp = newSteps[index] - newSteps[index] = newSteps[swapIndex] - newSteps[swapIndex] = temp - reorderStepsMutation.mutate(newSteps.map(s => s.id)) - } - - const handleSave = () => { - updateMutation.mutate({ - name: editName, - description: editDesc || null, - batch_portions: 1, - prep_time_minutes: editPrep ? parseInt(editPrep) : null, - cook_time_minutes: editCook ? parseInt(editCook) : null, - notes: editNotes || null, - menu_section_id: editSection ? parseInt(editSection) : null, - kds_menu_item_name: editKds || null, - sambapos_portion_name: recipe?.sambapos_portion_name || null, - gross_sell_price: grossPrice && parseFloat(grossPrice) > 0 ? parseFloat(grossPrice) : null, - }) - } - - const handlePrint = (format: string) => { - window.open(`/kitchen/api/recipes/${recipeId}/print?format=${format}&token=${token}`, '_blank') - } - - if (!recipe) return
Loading dish...
- - const totalCost = costData?.total_cost_recent - const costPerPortion = costData?.cost_per_portion - - return ( -
- {/* Header */} -
- -
- - - - {isDirty && } -
-
- - {/* Recipe metadata */} -
-
- { setEditName(e.target.value); setIsDirty(true) }} - style={{ ...styles.nameInput, flex: 1 }} - /> - DISH -
- - {dishMenus && dishMenus.length > 0 && ( -
- Published on: {dishMenus.map((m, i) => ( - - {i > 0 && ', '} - {m.menu_name} - - ))} -
- )} - -
-
- - -
-
- - { setEditPrep(e.target.value); setIsDirty(true) }} style={styles.input} /> -
-
- - { setEditCook(e.target.value); setIsDirty(true) }} style={styles.input} /> -
-
- - -