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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-13 10:04:20 +00:00
parent 6f6e16c88f
commit ba075276b1
57 changed files with 15427 additions and 15274 deletions

View file

@ -5,7 +5,7 @@ import os
import tempfile import tempfile
import shutil import shutil
from datetime import datetime 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 fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@ -282,15 +282,21 @@ async def delete_backup(
@router.get("/{backup_id}/download") @router.get("/{backup_id}/download")
async def download_backup( async def download_backup(
backup_id: int, backup_id: int,
token: str, request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Download a backup file. Auth via token query param for direct browser downloads.""" """Download a backup file. Cookie auth preferred; ?token= accepted as fallback."""
from auth import get_current_user, require_cap_from_token current_user = None
if token:
current_user = await get_current_user_from_token(token, db) current_user = await get_current_user_from_token(token, db)
if not current_user: 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: if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")

View file

@ -6,7 +6,7 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional 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 fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, or_, delete, update from sqlalchemy import select, func, text, and_, or_, delete, update
@ -783,14 +783,20 @@ async def get_ingredient(
@router.get("/{ingredient_id}/label-image") @router.get("/{ingredient_id}/label-image")
async def get_label_image( async def get_label_image(
ingredient_id: int, ingredient_id: int,
token: str, request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Serve the stored label image for a prepackaged ingredient. """Serve the stored label image for a prepackaged ingredient. Cookie auth preferred; ?token= accepted as fallback."""
Uses token query param for auth (allows window.open / img src usage)."""
import os import os
from auth import get_current_user, require_cap_from_token user = None
user = await get_current_user_from_token(token, db) 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: if not user:
raise HTTPException(401, "Not authenticated") raise HTTPException(401, "Not authenticated")
result = await db.execute( result = await db.execute(

View file

@ -118,7 +118,7 @@ def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int
return {} return {}
DATA_DIR = "/app/data" DATA_DIR = os.getenv("DATA_DIR", "/app/data")
# Response Models # Response Models
@ -549,7 +549,11 @@ async def upload_invoice(
status=InvoiceStatus.PENDING status=InvoiceStatus.PENDING
) )
db.add(invoice) db.add(invoice)
await db.commit() try:
await db.commit()
except Exception:
os.remove(filepath)
raise
await db.refresh(invoice) await db.refresh(invoice)
background_tasks.add_task( 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: except Exception as e:
logger.warning(f"Ingredient price auto-update failed (non-critical): {e}") logger.warning(f"Ingredient price auto-update failed (non-critical): {e}")
# Run duplicate detection # Run duplicate detection (non-critical — log and continue on failure)
detector = DuplicateDetector(db, kitchen_id) try:
duplicates = await detector.check_duplicates(invoice) detector = DuplicateDetector(db, kitchen_id)
duplicates = await detector.check_duplicates(invoice)
if duplicates["firm_duplicate"]: if duplicates["firm_duplicate"]:
invoice.duplicate_status = "firm_duplicate" invoice.duplicate_status = "firm_duplicate"
invoice.duplicate_of_id = duplicates["firm_duplicate"].id invoice.duplicate_of_id = duplicates["firm_duplicate"].id
elif duplicates["possible_duplicates"]: elif duplicates["possible_duplicates"]:
invoice.duplicate_status = "possible_duplicate" invoice.duplicate_status = "possible_duplicate"
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
if duplicates["related_documents"]: if duplicates["related_documents"]:
invoice.related_document_id = duplicates["related_documents"][0].id 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 invoice.status = InvoiceStatus.PROCESSED
await db.commit() 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}") f"duplicate_status={invoice.duplicate_status}")
except Exception as e: except Exception as e:
logger.error(f"OCR processing error for invoice {invoice_id}: {e}") logger.error(f"OCR processing error for invoice {invoice_id}: {e}", exc_info=True)
stmt = select(Invoice).where(Invoice.id == invoice_id) try:
db_result = await db.execute(stmt) await db.rollback()
invoice = db_result.scalar_one() stmt = select(Invoice).where(Invoice.id == invoice_id)
invoice.status = InvoiceStatus.PROCESSED db_result = await db.execute(stmt)
invoice.ocr_raw_text = f"Error: {str(e)}" invoice = db_result.scalar_one()
await db.commit() 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) @router.get("/", response_model=InvoiceListResponse)
@ -2106,19 +2117,26 @@ async def get_invoice_ocr_data(
async def get_line_item_preview( async def get_line_item_preview(
invoice_id: int, invoice_id: int,
line_number: int, line_number: int,
token: str, request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get a cropped image preview of a specific line item from the invoice OCR bounding box.""" """Get a cropped image preview of a specific line item from the invoice OCR bounding box."""
import json as json_module import json as json_module
import io import io
from auth import get_current_user, require_cap_from_token
from starlette.responses import Response from starlette.responses import Response
from services.file_archival_service import FileArchivalService 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: 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) 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, invoice_id: int,
line_number: int, line_number: int,
field_name: str, field_name: str,
token: str, request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get a cropped image preview of a specific field within a line item (e.g. product_code).""" """Get a cropped image preview of a specific field within a line item (e.g. product_code)."""
import json as json_module import json as json_module
import io import io
from auth import get_current_user, require_cap_from_token
from starlette.responses import Response from starlette.responses import Response
from services.file_archival_service import FileArchivalService from services.file_archival_service import FileArchivalService
@ -2234,9 +2252,16 @@ async def get_line_item_field_preview(
if not azure_key: if not azure_key:
raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}") 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: 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) 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: def _generalize_invoice_number_pattern(sample: str) -> str:
""" r"""
Convert a known invoice number into a regex that matches similar-shaped numbers. 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. Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers.
e.g. 'ID304574' r'\bID\d{5,7}\b' e.g. 'ID304574' -> r'\bID\d{5,7}\b'
'INV-00123' r'\bINV-\d{4,6}\b' 'INV-00123' -> r'\bINV-\d{4,6}\b'
""" """
parts = [] parts = []
i = 0 i = 0

View file

@ -10,7 +10,7 @@ from decimal import Decimal
from typing import Optional from typing import Optional
from html import escape as html_escape 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 fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func, delete 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") @router.get("/{po_id}/preview")
async def preview_purchase_order( async def preview_purchase_order(
po_id: int, po_id: int,
token: Optional[str] = None, request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Return a print-friendly HTML preview of the purchase order (query-param auth).""" """Return a print-friendly HTML preview of the purchase order. Cookie auth preferred; ?token= accepted as fallback."""
if not token: current_user = None
raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token") if token:
current_user = await get_current_user_from_token(token, db) 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: if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
po = await _load_po(db, po_id, current_user.kitchen_id) po = await _load_po(db, po_id, current_user.kitchen_id)

View file

@ -10,7 +10,7 @@ from decimal import Decimal
from typing import Optional from typing import Optional
from html import escape as html_escape 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 fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, delete from sqlalchemy import select, func, text, and_, delete
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
DATA_DIR = "/app/data" DATA_DIR = os.getenv("DATA_DIR", "/app/data")
# ── Pydantic schemas ───────────────────────────────────────────────────────── # ── Pydantic schemas ─────────────────────────────────────────────────────────
@ -686,16 +686,33 @@ async def list_recipes(
result = await db.execute(query.order_by(Recipe.name)) result = await db.execute(query.order_by(Recipe.name))
recipes = result.scalars().all() 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 = [] items = []
for r in recipes: for r in recipes:
# Get latest cost snapshot snap = snap_map.get(r.id)
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()
# Get flag summary (lightweight) # Get flag summary (lightweight)
from api.food_flags import compute_recipe_flags from api.food_flags import compute_recipe_flags
@ -1529,13 +1546,18 @@ async def upload_image(
async def serve_image( async def serve_image(
recipe_id: int, recipe_id: int,
image_id: int, image_id: int,
request: Request,
token: Optional[str] = Query(None), token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# img tags can't send Authorization header, so auth via query param user = None
if not token: if token:
raise HTTPException(401, "Not authenticated") user = await get_current_user_from_token(token, db)
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: if not user:
raise HTTPException(401, "Not authenticated") raise HTTPException(401, "Not authenticated")
await _get_recipe(recipe_id, user.kitchen_id, db) 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 needed_unit = sr.portions_needed_unit or child_output_unit
portions_needed_raw = float(sr.portions_needed) * scale_factor portions_needed_raw = float(sr.portions_needed) * scale_factor
portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit) 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 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 = 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 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") @router.get("/{recipe_id}/print")
async def print_recipe( async def print_recipe(
recipe_id: int, recipe_id: int,
request: Request,
format: str = Query("full"), # "full" | "kitchen" format: str = Query("full"), # "full" | "kitchen"
token: Optional[str] = Query(None), token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# window.open() can't send Authorization header, so auth via query param user = None
if not token: if token:
raise HTTPException(status_code=401, detail="Not authenticated") user = await get_current_user_from_token(token, db)
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: if not user:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
recipe = await _get_recipe(recipe_id, user.kitchen_id, db) 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 from api.food_flags import compute_recipe_flags
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) 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) 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.""" """Generate print-optimised HTML for a recipe."""
esc = html_escape esc = html_escape
name = esc(recipe_data.get("name", "")) 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: if not plating_images:
plating_images = recipe_data.get("images", [])[:1] plating_images = recipe_data.get("images", [])[:1]
for img in plating_images: 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'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />' images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
time_info = "" time_info = ""

View file

@ -14,9 +14,9 @@ if DATABASE_URL.startswith("postgresql://"):
engine = create_async_engine( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=False, echo=False,
pool_size=10, # Default is 5 pool_size=int(os.getenv("DB_POOL_SIZE", "10")),
max_overflow=20, # Default is 10 - allows burst to 30 connections max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "20")),
pool_pre_ping=True # Verify connections are alive before use pool_pre_ping=True,
) )
AsyncSessionLocal = async_sessionmaker( AsyncSessionLocal = async_sessionmaker(

View file

@ -1,9 +1,16 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' 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 Layout from './components/Layout'
import { can } from './types'
// Re-export so existing components can keep `import { useAuth } from '../App'` // 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 : <Navigate to="/dashboard" replace />
}
// Pages // Pages
import Settings from './pages/Settings' import Settings from './pages/Settings'
@ -56,48 +63,48 @@ export default function App() {
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
{/* Invoices */} {/* Invoices */}
<Route path="/upload" element={<Upload />} /> <Route path="/upload" element={<ProtectedRoute cap="invoices" element={<Upload />} />} />
<Route path="/invoices" element={<InvoiceList />} /> <Route path="/invoices" element={<ProtectedRoute cap="invoices" element={<InvoiceList />} />} />
<Route path="/invoice/:id" element={<Review />} /> <Route path="/invoice/:id" element={<ProtectedRoute cap="invoices" element={<Review />} />} />
<Route path="/disputes" element={<Disputes />} /> <Route path="/disputes" element={<ProtectedRoute cap="disputes" element={<Disputes />} />} />
<Route path="/search/invoices" element={<SearchInvoices />} /> <Route path="/search/invoices" element={<ProtectedRoute cap="invoices" element={<SearchInvoices />} />} />
<Route path="/search/line-items" element={<SearchLineItems />} /> <Route path="/search/line-items" element={<ProtectedRoute cap="invoices" element={<SearchLineItems />} />} />
<Route path="/purchase-orders" element={<PurchaseOrderList />} /> <Route path="/purchase-orders" element={<ProtectedRoute cap="orders" element={<PurchaseOrderList />} />} />
{/* Reports */} {/* Reports */}
<Route path="/gp" element={<GPReport />} /> <Route path="/gp" element={<ProtectedRoute cap="view" element={<GPReport />} />} />
<Route path="/purchases-report" element={<PurchasesReport />} /> <Route path="/purchases-report" element={<ProtectedRoute cap="view" element={<PurchasesReport />} />} />
<Route path="/purchases" element={<Purchases />} /> <Route path="/purchases" element={<ProtectedRoute cap="invoices" element={<Purchases />} />} />
<Route path="/purchases/reconcile" element={<ReconcilePurchases />} /> <Route path="/purchases/reconcile" element={<ProtectedRoute cap="view" element={<ReconcilePurchases />} />} />
<Route path="/allowances-report" element={<AllowancesReport />} /> <Route path="/allowances-report" element={<ProtectedRoute cap="view" element={<AllowancesReport />} />} />
<Route path="/sales-gp" element={<SalesGPReport />} /> <Route path="/sales-gp" element={<ProtectedRoute cap="view" element={<SalesGPReport />} />} />
<Route path="/usage-variance" element={<UsageVarianceReport />} /> <Route path="/usage-variance" element={<ProtectedRoute cap="view" element={<UsageVarianceReport />} />} />
{/* Kitchen */} {/* Kitchen */}
<Route path="/logbook" element={<WastageLogbook />} /> <Route path="/logbook" element={<ProtectedRoute cap="logbook" element={<WastageLogbook />} />} />
<Route path="/budget" element={<Budget />} /> <Route path="/budget" element={<ProtectedRoute cap="logbook" element={<Budget />} />} />
<Route path="/event-orders" element={<EventOrders />} /> <Route path="/event-orders" element={<ProtectedRoute cap="orders" element={<EventOrders />} />} />
<Route path="/event-orders/:id" element={<EventOrderEditor />} /> <Route path="/event-orders/:id" element={<ProtectedRoute cap="orders" element={<EventOrderEditor />} />} />
{/* Bookings */} {/* Bookings */}
<Route path="/resos" element={<ResosData />} /> <Route path="/resos" element={<ProtectedRoute cap="view" element={<ResosData />} />} />
<Route path="/resos-stats" element={<BookingsStats />} /> <Route path="/resos-stats" element={<ProtectedRoute cap="view" element={<BookingsStats />} />} />
<Route path="/residents-table-chart" element={<ResidentsTableChart />} /> <Route path="/residents-table-chart" element={<ProtectedRoute cap="view" element={<ResidentsTableChart />} />} />
<Route path="/newbook" element={<NewbookData />} /> <Route path="/newbook" element={<ProtectedRoute cap="view" element={<NewbookData />} />} />
{/* Recipes */} {/* Recipes */}
<Route path="/ingredients" element={<Ingredients />} /> <Route path="/ingredients" element={<ProtectedRoute cap="recipes" element={<Ingredients />} />} />
<Route path="/recipes" element={<RecipeList />} /> <Route path="/recipes" element={<ProtectedRoute cap="recipes" element={<RecipeList />} />} />
<Route path="/recipes/:id" element={<RecipeEditor />} /> <Route path="/recipes/:id" element={<ProtectedRoute cap="recipes" element={<RecipeEditor />} />} />
<Route path="/dishes" element={<DishList />} /> <Route path="/dishes" element={<ProtectedRoute cap="recipes" element={<DishList />} />} />
<Route path="/dishes/:id" element={<DishEditor />} /> <Route path="/dishes/:id" element={<ProtectedRoute cap="recipes" element={<DishEditor />} />} />
<Route path="/menus" element={<MenuList />} /> <Route path="/menus" element={<ProtectedRoute cap="menus" element={<MenuList />} />} />
<Route path="/menus/:id" element={<MenuEditor />} /> <Route path="/menus/:id" element={<ProtectedRoute cap="menus" element={<MenuEditor />} />} />
<Route path="/allergens" element={<BulkAllergens />} /> <Route path="/allergens" element={<ProtectedRoute cap="recipes" element={<BulkAllergens />} />} />
<Route path="/price-impact" element={<PriceImpact />} /> <Route path="/price-impact" element={<ProtectedRoute cap="recipes" element={<PriceImpact />} />} />
{/* Settings */} {/* Settings */}
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<ProtectedRoute cap="settings" element={<Settings />} />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route> </Route>

View file

@ -255,7 +255,7 @@ export default function AllowancesReport() {
queryKey: ['allowances-summary', submittedFromDate, submittedToDate], queryKey: ['allowances-summary', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/allowances/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { 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') if (!res.ok) throw new Error('Failed to fetch allowances summary')
return res.json() return res.json()
@ -269,7 +269,7 @@ export default function AllowancesReport() {
queryKey: ['allowances-daily', submittedFromDate, submittedToDate], queryKey: ['allowances-daily', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/allowances/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { 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') if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json() return res.json()
@ -283,7 +283,7 @@ export default function AllowancesReport() {
queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate], queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/disputes/period-summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { 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') if (!res.ok) throw new Error('Failed to fetch disputes summary')
return res.json() return res.json()

View file

@ -255,12 +255,12 @@ export default function Budget() {
queryKey: ['budget', 'weekly', weekOffset], queryKey: ['budget', 'weekly', weekOffset],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset}`, { 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') if (!res.ok) throw new Error('Failed to fetch budget data')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch prior 2 weeks for chart comparison // Fetch prior 2 weeks for chart comparison
@ -268,23 +268,23 @@ export default function Budget() {
queryKey: ['budget', 'weekly', weekOffset - 1], queryKey: ['budget', 'weekly', weekOffset - 1],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 1}`, { const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 1}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const { data: prevWeek2 } = useQuery<WeeklyBudgetResponse>({ const { data: prevWeek2 } = useQuery<WeeklyBudgetResponse>({
queryKey: ['budget', 'weekly', weekOffset - 2], queryKey: ['budget', 'weekly', weekOffset - 2],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 2}`, { const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 2}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const goToPreviousWeek = () => setWeekOffset((prev) => prev - 1) const goToPreviousWeek = () => setWeekOffset((prev) => prev - 1)
@ -299,7 +299,7 @@ export default function Budget() {
queryKey: ['cover-overrides', 'weekly', weekOffset], queryKey: ['cover-overrides', 'weekly', weekOffset],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/cover-overrides/weekly?week_offset=${weekOffset}`, { 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') if (!res.ok) throw new Error('Failed to fetch override data')
return res.json() return res.json()
@ -313,7 +313,7 @@ export default function Budget() {
queryFn: async () => { queryFn: async () => {
const res = await fetch( const res = await fetch(
`/kitchen/api/cost-distributions/weekly?week_start=${budgetData!.week_start}&week_end=${budgetData!.week_end}`, `/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') if (!res.ok) throw new Error('Failed to fetch distribution data')
return res.json() return res.json()
@ -336,7 +336,7 @@ export default function Budget() {
queryFn: async () => { queryFn: async () => {
const res = await fetch( const res = await fetch(
`/kitchen/api/resos/resident-covers?start_date=${budgetData!.week_start}&end_date=${budgetData!.week_end}`, `/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 {} if (!res.ok) return {}
const data = await res.json() const data = await res.json()
@ -349,7 +349,7 @@ export default function Budget() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/cover-overrides/snapshot', { const res = await fetch('/kitchen/api/cover-overrides/snapshot', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ week_offset: weekOffset }), body: JSON.stringify({ week_offset: weekOffset }),
}) })
if (!res.ok) throw new Error('Failed to create snapshot') if (!res.ok) throw new Error('Failed to create snapshot')
@ -385,7 +385,7 @@ export default function Budget() {
const [overrideDate, period] = key.split('|') const [overrideDate, period] = key.split('|')
return fetch('/kitchen/api/cover-overrides', { return fetch('/kitchen/api/cover-overrides', {
method: 'PUT', 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 }), body: JSON.stringify({ override_date: overrideDate, period, override_covers: value }),
}) })
})) }))
@ -400,7 +400,7 @@ export default function Budget() {
const deleteOverride = async (id: number) => { const deleteOverride = async (id: number) => {
await fetch(`/kitchen/api/cover-overrides/${id}`, { await fetch(`/kitchen/api/cover-overrides/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
refetchOverrides() refetchOverrides()
refetch() refetch()
@ -409,7 +409,7 @@ export default function Budget() {
const saveSpendRate = async (period: string, food: number | null, drinks: number | null) => { const saveSpendRate = async (period: string, food: number | null, drinks: number | null) => {
await fetch('/kitchen/api/cover-overrides/spend-rates', { await fetch('/kitchen/api/cover-overrides/spend-rates', {
method: 'PUT', 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 }), body: JSON.stringify({ week_offset: weekOffset, period, food_spend: food, drinks_spend: drinks }),
}) })
refetchOverrides() refetchOverrides()
@ -806,7 +806,7 @@ export default function Budget() {
}} }}
onBlur={(e) => { onBlur={(e) => {
const inputGross = parseFloat(e.target.value) 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 const netVal = Math.round((inputGross / overrideData.vat_rate) * 100) / 100
saveSpendRate(sr.period, netVal, null) saveSpendRate(sr.period, netVal, null)
} }

View file

@ -1,438 +1,438 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
interface FlagInfo { interface FlagInfo {
id: number id: number
food_flag_id: number food_flag_id: number
flag_name: string flag_name: string
flag_code: string | null flag_code: string | null
category_name: string category_name: string
propagation_type: string propagation_type: string
source: string source: string
} }
interface AllergenSuggestion { interface AllergenSuggestion {
flag_id: number flag_id: number
flag_name: string flag_name: string
flag_code: string | null flag_code: string | null
category_name: string category_name: string
matched_keywords: string[] matched_keywords: string[]
} }
interface IngredientItem { interface IngredientItem {
id: number id: number
name: string name: string
category_id: number | null category_id: number | null
category_name: string | null category_name: string | null
standard_unit: string standard_unit: string
notes: string | null notes: string | null
is_prepackaged: boolean is_prepackaged: boolean
product_ingredients: string | null product_ingredients: string | null
flags: FlagInfo[] flags: FlagInfo[]
} }
interface FoodFlagItem { interface FoodFlagItem {
id: number id: number
name: string name: string
code: string | null code: string | null
propagation_type: string propagation_type: string
} }
interface FoodFlagCategoryItem { interface FoodFlagCategoryItem {
id: number id: number
name: string name: string
propagation_type: string propagation_type: string
required: boolean required: boolean
flags: FoodFlagItem[] flags: FoodFlagItem[]
} }
interface IngredientCategory { interface IngredientCategory {
id: number id: number
name: string name: string
} }
export default function BulkAllergens() { export default function BulkAllergens() {
const { token } = useAuth() const { token } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('') const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnassessedOnly, setShowUnassessedOnly] = useState(false) const [showUnassessedOnly, setShowUnassessedOnly] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
// Fetch all non-archived ingredients // Fetch all non-archived ingredients
const { data: ingredients } = useQuery<IngredientItem[]>({ const { data: ingredients } = useQuery<IngredientItem[]>({
queryKey: ['ingredients-bulk'], queryKey: ['ingredients-bulk'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients?limit=9999', { const res = await fetch('/kitchen/api/ingredients?limit=9999', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch ingredients') if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch ingredient categories // Fetch ingredient categories
const { data: categories } = useQuery<IngredientCategory[]>({ const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'], queryKey: ['ingredient-categories'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/categories', { const res = await fetch('/kitchen/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch flag categories (only required ones shown as columns) // Fetch flag categories (only required ones shown as columns)
const { data: flagCategories } = useQuery<FoodFlagCategoryItem[]>({ const { data: flagCategories } = useQuery<FoodFlagCategoryItem[]>({
queryKey: ['food-flag-categories-full'], queryKey: ['food-flag-categories-full'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/food-flags/categories', { const res = await fetch('/kitchen/api/food-flags/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch flag categories') if (!res.ok) throw new Error('Failed to fetch flag categories')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch bulk nones (ingredient_id -> category_ids where None is set) // Fetch bulk nones (ingredient_id -> category_ids where None is set)
const { data: bulkNones } = useQuery<Record<number, number[]>>({ const { data: bulkNones } = useQuery<Record<number, number[]>>({
queryKey: ['bulk-nones'], queryKey: ['bulk-nones'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/bulk-nones', { const res = await fetch('/kitchen/api/ingredients/bulk-nones', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return {} if (!res.ok) return {}
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch suggestions for ALL ingredients in bulk (single request) // Fetch suggestions for ALL ingredients in bulk (single request)
const { data: allSuggestions } = useQuery<Record<number, AllergenSuggestion[]>>({ const { data: allSuggestions } = useQuery<Record<number, AllergenSuggestion[]>>({
queryKey: ['bulk-suggestions'], queryKey: ['bulk-suggestions'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/food-flags/suggest/bulk', { const res = await fetch('/kitchen/api/food-flags/suggest/bulk', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return {} if (!res.ok) return {}
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Toggle a flag on an ingredient // Toggle a flag on an ingredient
const toggleFlagMutation = useMutation({ const toggleFlagMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => { mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => {
// Get current flags for this ingredient // Get current flags for this ingredient
const ing = ingredients?.find(i => i.id === ingredientId) const ing = ingredients?.find(i => i.id === ingredientId)
const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || [] const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || []
let newFlagIds: number[] let newFlagIds: number[]
if (action === 'add') { if (action === 'add') {
newFlagIds = [...currentFlagIds, flagId] newFlagIds = [...currentFlagIds, flagId]
} else { } else {
newFlagIds = currentFlagIds.filter(id => id !== flagId) newFlagIds = currentFlagIds.filter(id => id !== flagId)
} }
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, { const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_ids: newFlagIds }), body: JSON.stringify({ food_flag_ids: newFlagIds }),
}) })
if (!res.ok) throw new Error('Failed to update flags') if (!res.ok) throw new Error('Failed to update flags')
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
}, },
}) })
// Toggle None for a category on an ingredient // Toggle None for a category on an ingredient
const toggleNoneMutation = useMutation({ const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => { mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => {
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, { const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: categoryId }), body: JSON.stringify({ category_id: categoryId }),
}) })
if (!res.ok) throw new Error('Failed to toggle none') if (!res.ok) throw new Error('Failed to toggle none')
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] }) queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] }) queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
}, },
}) })
const toggleExpanded = (ing: IngredientItem) => { const toggleExpanded = (ing: IngredientItem) => {
setExpandedId(expandedId === ing.id ? null : ing.id) setExpandedId(expandedId === ing.id ? null : ing.id)
} }
// Only show required categories as column groups // Only show required categories as column groups
const requiredCategories = flagCategories?.filter(c => c.required) || [] const requiredCategories = flagCategories?.filter(c => c.required) || []
// Build a flat list of flag columns // Build a flat list of flag columns
const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = [] const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = []
for (const cat of requiredCategories) { for (const cat of requiredCategories) {
for (const f of cat.flags) { for (const f of cat.flags) {
flagColumns.push({ flagColumns.push({
flagId: f.id, flagId: f.id,
flagName: f.name, flagName: f.name,
flagCode: f.code, flagCode: f.code,
categoryId: cat.id, categoryId: cat.id,
categoryName: cat.name, categoryName: cat.name,
propagation: cat.propagation_type, propagation: cat.propagation_type,
}) })
} }
} }
// Filter ingredients // Filter ingredients
const filtered = (ingredients || []).filter(ing => { const filtered = (ingredients || []).filter(ing => {
if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false
if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false
if (showUnassessedOnly) { if (showUnassessedOnly) {
// Check if ingredient is unassessed for any required category // Check if ingredient is unassessed for any required category
const nones = bulkNones?.[ing.id] || [] const nones = bulkNones?.[ing.id] || []
for (const cat of requiredCategories) { for (const cat of requiredCategories) {
if (nones.includes(cat.id)) continue // None set for this category 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)) const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id))
if (!hasFlagInCat) return true // Unassessed for this category if (!hasFlagInCat) return true // Unassessed for this category
} }
return false return false
} }
return true return true
}) })
return ( return (
<div style={styles.page}> <div style={styles.page}>
<h2 style={{ margin: '0 0 0.75rem 0' }}>Bulk Allergen Assessment</h2> <h2 style={{ margin: '0 0 0.75rem 0' }}>Bulk Allergen Assessment</h2>
{/* Filters */} {/* Filters */}
<div style={styles.filterBar}> <div style={styles.filterBar}>
<input <input
type="text" type="text"
placeholder="Search ingredients..." placeholder="Search ingredients..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput} style={styles.searchInput}
/> />
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}> <select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option> <option value="">All Categories</option>
{categories?.map(c => ( {categories?.map(c => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.85rem', cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.85rem', cursor: 'pointer' }}>
<input <input
type="checkbox" type="checkbox"
checked={showUnassessedOnly} checked={showUnassessedOnly}
onChange={(e) => setShowUnassessedOnly(e.target.checked)} onChange={(e) => setShowUnassessedOnly(e.target.checked)}
/> />
Unassessed only Unassessed only
</label> </label>
<span style={{ fontSize: '0.85rem', color: '#666', marginLeft: 'auto' }}> <span style={{ fontSize: '0.85rem', color: '#666', marginLeft: 'auto' }}>
{filtered.length} ingredients {filtered.length} ingredients
</span> </span>
</div> </div>
{flagColumns.length === 0 ? ( {flagColumns.length === 0 ? (
<div style={styles.emptyState}> <div style={styles.emptyState}>
No required flag categories found. Go to Settings &gt; Food Flags and mark allergen categories as "Required". No required flag categories found. Go to Settings &gt; Food Flags and mark allergen categories as "Required".
</div> </div>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={styles.table}> <table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 3 }}> <thead style={{ position: 'sticky', top: 0, zIndex: 3 }}>
<tr> <tr>
<th style={{ ...styles.th, position: 'sticky', left: 0, background: '#fafafa', zIndex: 4, minWidth: '200px' }}> <th style={{ ...styles.th, position: 'sticky', left: 0, background: '#fafafa', zIndex: 4, minWidth: '200px' }}>
Ingredient Ingredient
</th> </th>
{requiredCategories.map(cat => ( {requiredCategories.map(cat => (
<th <th
key={`cat-none-${cat.id}`} key={`cat-none-${cat.id}`}
style={{ ...styles.th, textAlign: 'center', fontSize: '0.7rem', background: '#f0fdf4', minWidth: '50px' }} style={{ ...styles.th, textAlign: 'center', fontSize: '0.7rem', background: '#f0fdf4', minWidth: '50px' }}
title={`None apply for ${cat.name}`} title={`None apply for ${cat.name}`}
> >
None None
</th> </th>
))} ))}
{flagColumns.map(col => ( {flagColumns.map(col => (
<th <th
key={col.flagId} key={col.flagId}
style={{ ...styles.th, textAlign: 'center' as const, minWidth: '45px', writingMode: 'vertical-lr' as const, fontSize: '0.7rem' }} style={{ ...styles.th, textAlign: 'center' as const, minWidth: '45px', writingMode: 'vertical-lr' as const, fontSize: '0.7rem' }}
title={`${col.flagName} (${col.categoryName})`} title={`${col.flagName} (${col.categoryName})`}
> >
{col.flagCode || col.flagName} {col.flagCode || col.flagName}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
{filtered.map(ing => { {filtered.map(ing => {
const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id)) const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id))
const nones = bulkNones?.[ing.id] || [] const nones = bulkNones?.[ing.id] || []
const isExpanded = expandedId === ing.id const isExpanded = expandedId === ing.id
const ingSuggestions = allSuggestions?.[ing.id] const ingSuggestions = allSuggestions?.[ing.id]
const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id)) const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id))
const hasPendingSuggestions = !!pendingSuggestions?.length const hasPendingSuggestions = !!pendingSuggestions?.length
const totalCols = 1 + requiredCategories.length + flagColumns.length const totalCols = 1 + requiredCategories.length + flagColumns.length
return ( return (
<tbody key={ing.id}> <tbody key={ing.id}>
<tr style={styles.tr}> <tr style={styles.tr}>
<td <td
style={{ style={{
...styles.td, position: 'sticky', left: 0, zIndex: 1, fontWeight: 500, cursor: 'pointer', ...styles.td, position: 'sticky', left: 0, zIndex: 1, fontWeight: 500, cursor: 'pointer',
background: isExpanded ? '#f0f7ff' : hasPendingSuggestions ? '#fffbeb' : 'white', background: isExpanded ? '#f0f7ff' : hasPendingSuggestions ? '#fffbeb' : 'white',
borderLeft: hasPendingSuggestions ? '3px solid #f59e0b' : undefined, borderLeft: hasPendingSuggestions ? '3px solid #f59e0b' : undefined,
}} }}
onClick={() => toggleExpanded(ing)} onClick={() => toggleExpanded(ing)}
title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'} title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'}
> >
<span style={{ fontSize: '0.65rem', color: '#888', marginRight: '4px' }}>{isExpanded ? '\u25BC' : '\u25B6'}</span> <span style={{ fontSize: '0.65rem', color: '#888', marginRight: '4px' }}>{isExpanded ? '\u25BC' : '\u25B6'}</span>
{ing.name} {ing.name}
{ing.category_name && ( {ing.category_name && (
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '6px' }}>{ing.category_name}</span> <span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '6px' }}>{ing.category_name}</span>
)} )}
{ing.is_prepackaged && ( {ing.is_prepackaged && (
<span style={{ fontSize: '0.6rem', color: '#6366f1', marginLeft: '6px', fontWeight: 600 }}>PKG</span> <span style={{ fontSize: '0.6rem', color: '#6366f1', marginLeft: '6px', fontWeight: 600 }}>PKG</span>
)} )}
</td> </td>
{/* None columns per required category */} {/* None columns per required category */}
{requiredCategories.map(cat => { {requiredCategories.map(cat => {
const isNone = nones.includes(cat.id) const isNone = nones.includes(cat.id)
return ( return (
<td key={`none-${cat.id}`} style={{ ...styles.td, textAlign: 'center', background: isNone ? '#dcfce7' : undefined }}> <td key={`none-${cat.id}`} style={{ ...styles.td, textAlign: 'center', background: isNone ? '#dcfce7' : undefined }}>
<input <input
type="checkbox" type="checkbox"
checked={isNone} checked={isNone}
onChange={() => toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })} onChange={() => toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })}
disabled={toggleNoneMutation.isPending} disabled={toggleNoneMutation.isPending}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
title={`None apply for ${cat.name}`} title={`None apply for ${cat.name}`}
/> />
</td> </td>
) )
})} })}
{/* Flag columns */} {/* Flag columns */}
{flagColumns.map(col => { {flagColumns.map(col => {
const isChecked = ingFlagIds.has(col.flagId) const isChecked = ingFlagIds.has(col.flagId)
const isNoneForCategory = nones.includes(col.categoryId) const isNoneForCategory = nones.includes(col.categoryId)
const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId) const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId)
return ( return (
<td <td
key={col.flagId} key={col.flagId}
style={{ style={{
...styles.td, ...styles.td,
textAlign: 'center', textAlign: 'center',
background: isChecked background: isChecked
? (col.propagation === 'contains' ? '#fef2f2' : '#dcfce7') ? (col.propagation === 'contains' ? '#fef2f2' : '#dcfce7')
: isSuggested ? '#fffbeb' : isSuggested ? '#fffbeb'
: isNoneForCategory ? '#f8f8f8' : undefined, : isNoneForCategory ? '#f8f8f8' : undefined,
opacity: isNoneForCategory ? 0.4 : 1, opacity: isNoneForCategory ? 0.4 : 1,
}} }}
> >
<input <input
type="checkbox" type="checkbox"
checked={isChecked} checked={isChecked}
onChange={() => toggleFlagMutation.mutate({ onChange={() => toggleFlagMutation.mutate({
ingredientId: ing.id, ingredientId: ing.id,
flagId: col.flagId, flagId: col.flagId,
action: isChecked ? 'remove' : 'add', action: isChecked ? 'remove' : 'add',
})} })}
disabled={isNoneForCategory || toggleFlagMutation.isPending} disabled={isNoneForCategory || toggleFlagMutation.isPending}
style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }} style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }}
title={col.flagName + (isSuggested ? ' (suggested)' : '')} title={col.flagName + (isSuggested ? ' (suggested)' : '')}
/> />
</td> </td>
) )
})} })}
</tr> </tr>
{/* Expanded detail row */} {/* Expanded detail row */}
{isExpanded && ( {isExpanded && (
<tr> <tr>
<td colSpan={totalCols} style={{ padding: '0.5rem 0.75rem', background: '#f8fafc', borderBottom: '2px solid #e0e0e0' }}> <td colSpan={totalCols} style={{ padding: '0.5rem 0.75rem', background: '#f8fafc', borderBottom: '2px solid #e0e0e0' }}>
<div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem' }}> <div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem' }}>
{/* Notes */} {/* Notes */}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Notes</div> <div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Notes</div>
<div style={{ color: ing.notes ? '#333' : '#bbb', whiteSpace: 'pre-wrap' }}> <div style={{ color: ing.notes ? '#333' : '#bbb', whiteSpace: 'pre-wrap' }}>
{ing.notes || 'No notes'} {ing.notes || 'No notes'}
</div> </div>
</div> </div>
{/* Product ingredients */} {/* Product ingredients */}
<div style={{ flex: 2 }}> <div style={{ flex: 2 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}> <div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>
Label Ingredients {ing.is_prepackaged && <span style={{ color: '#6366f1' }}>(prepackaged)</span>} Label Ingredients {ing.is_prepackaged && <span style={{ color: '#6366f1' }}>(prepackaged)</span>}
</div> </div>
<div style={{ color: ing.product_ingredients ? '#333' : '#bbb', whiteSpace: 'pre-wrap', maxHeight: '80px', overflow: 'auto' }}> <div style={{ color: ing.product_ingredients ? '#333' : '#bbb', whiteSpace: 'pre-wrap', maxHeight: '80px', overflow: 'auto' }}>
{ing.product_ingredients || 'Not available'} {ing.product_ingredients || 'Not available'}
</div> </div>
</div> </div>
{/* Allergen suggestions */} {/* Allergen suggestions */}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Keyword Suggestions</div> <div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Keyword Suggestions</div>
{!pendingSuggestions?.length ? ( {!pendingSuggestions?.length ? (
<div style={{ color: '#bbb' }}>No suggestions</div> <div style={{ color: '#bbb' }}>No suggestions</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
{pendingSuggestions.map(s => ( {pendingSuggestions.map(s => (
<div <div
key={s.flag_id} key={s.flag_id}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.15rem 0.3rem', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '4px' }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.15rem 0.3rem', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '4px' }}
> >
<span> <span>
<strong style={{ fontSize: '0.75rem' }}>{s.flag_name}</strong> <strong style={{ fontSize: '0.75rem' }}>{s.flag_name}</strong>
<span style={{ color: '#999', fontSize: '0.65rem', marginLeft: '0.25rem' }}> <span style={{ color: '#999', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
{s.matched_keywords.join(', ')} {s.matched_keywords.join(', ')}
</span> </span>
</span> </span>
<button <button
onClick={() => { onClick={() => {
const cat = requiredCategories.find(c => c.flags.some(f => f.id === s.flag_id)) const cat = requiredCategories.find(c => c.flags.some(f => f.id === s.flag_id))
if (cat) toggleFlagMutation.mutate({ ingredientId: ing.id, flagId: s.flag_id, action: 'add' }) if (cat) toggleFlagMutation.mutate({ ingredientId: ing.id, flagId: s.flag_id, action: 'add' })
}} }}
disabled={toggleFlagMutation.isPending} disabled={toggleFlagMutation.isPending}
style={{ background: '#f59e0b', color: 'white', border: 'none', borderRadius: '3px', padding: '0.1rem 0.3rem', fontSize: '0.65rem', fontWeight: 600, cursor: 'pointer' }} style={{ background: '#f59e0b', color: 'white', border: 'none', borderRadius: '3px', padding: '0.1rem 0.3rem', fontSize: '0.65rem', fontWeight: 600, cursor: 'pointer' }}
> >
Apply Apply
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
) )
})} })}
</table> </table>
</div> </div>
)} )}
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' }, page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' },
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const }, 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' }, 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' }, 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' }, 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)' }, 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' }, 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' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' }, td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' },
} }

View file

@ -32,12 +32,12 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC
queryKey: ['dishes-for-bulk'], queryKey: ['dishes-for-bulk'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', { const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Check flags for selected dishes // Check flags for selected dishes
@ -47,7 +47,7 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC
const results: Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }> = {} const results: Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }> = {}
for (const rid of selected) { for (const rid of selected) {
const res = await fetch(`/kitchen/api/food-flags/recipes/${rid}/flags`, { const res = await fetch(`/kitchen/api/food-flags/recipes/${rid}/flags`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() 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`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/bulk`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
division_id: divisionId, division_id: divisionId,
confirmed_by_name: confirmedBy.trim(), confirmed_by_name: confirmedBy.trim(),

View file

@ -124,7 +124,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
// Load existing distribution // Load existing distribution
setLoading(true) setLoading(true)
fetch(`/kitchen/api/cost-distributions/${distributionId}`, { 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(r => { if (!r.ok) throw new Error('Failed to load distribution'); return r.json() })
.then((data: DistributionDetail) => { .then((data: DistributionDetail) => {
@ -137,7 +137,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
// Load invoice availability // Load invoice availability
setLoading(true) setLoading(true)
fetch(`/kitchen/api/cost-distributions/invoice/${invoiceId}/availability`, { 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(r => { if (!r.ok) throw new Error('Failed to load invoice data'); return r.json() })
.then((data: InvoiceAvailability) => { .then((data: InvoiceAvailability) => {
@ -278,7 +278,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
const res = await fetch('/kitchen/api/cost-distributions/', { const res = await fetch('/kitchen/api/cost-distributions/', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
@ -302,7 +302,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
try { try {
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes }), body: JSON.stringify({ notes }),
}) })
if (!res.ok) throw new Error('Failed to update') if (!res.ok) throw new Error('Failed to update')
@ -322,7 +322,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
try { try {
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -345,7 +345,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount) if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount)
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {

View file

@ -71,9 +71,9 @@ export default function CreateDisputeModal({
mutationFn: async (data: CreateDisputeRequest) => { mutationFn: async (data: CreateDisputeRequest) => {
const res = await fetch('/kitchen/api/disputes', { const res = await fetch('/kitchen/api/disputes', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })

View file

@ -137,19 +137,19 @@ export default function Dashboard() {
queryKey: ['resos-settings'], queryKey: ['resos-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/resos/settings', { 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') if (!res.ok) throw new Error('Failed to fetch Resos settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const { data, isLoading, error } = useQuery<DashboardData>({ const { data, isLoading, error } = useQuery<DashboardData>({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/reports/dashboard', { const res = await fetch('/kitchen/api/reports/dashboard', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch dashboard') if (!res.ok) throw new Error('Failed to fetch dashboard')
return res.json() return res.json()
@ -160,7 +160,7 @@ export default function Dashboard() {
queryKey: ['resos-dashboard-covers'], queryKey: ['resos-dashboard-covers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/resos/dashboard/today-tomorrow', { 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') if (!res.ok) throw new Error('Failed to fetch Resos covers')
const data = await res.json() const data = await res.json()
@ -173,12 +173,12 @@ export default function Dashboard() {
queryKey: ['newbook-arrival-stats'], queryKey: ['newbook-arrival-stats'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/newbook/dashboard/arrivals?days=3', { 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') if (!res.ok) throw new Error('Failed to fetch arrival stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}) })
@ -194,12 +194,12 @@ export default function Dashboard() {
queryKey: ['upcoming-events'], queryKey: ['upcoming-events'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/calendar-events/dashboard/upcoming', { 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') if (!res.ok) throw new Error('Failed to fetch upcoming events')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 5 * 60 * 1000 // Cache for 5 minutes staleTime: 5 * 60 * 1000 // Cache for 5 minutes
}) })
@ -218,12 +218,12 @@ export default function Dashboard() {
queryKey: ['recipe-dashboard-stats'], queryKey: ['recipe-dashboard-stats'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/recipes/dashboard-stats', { 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') if (!res.ok) throw new Error('Failed to fetch recipe stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
@ -231,12 +231,12 @@ export default function Dashboard() {
queryKey: ['dispute-stats'], queryKey: ['dispute-stats'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/disputes/stats/summary', { 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') if (!res.ok) throw new Error('Failed to fetch dispute stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 5 * 60 * 1000 // Cache for 5 minutes staleTime: 5 * 60 * 1000 // Cache for 5 minutes
}) })

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -125,11 +125,11 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({ const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({
queryKey: ['settings'], queryKey: ['settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings', { headers: { Authorization: `Bearer ${token}` } }) const res = await fetch('/kitchen/api/settings', { credentials: 'include' })
if (!res.ok) return {} if (!res.ok) return {}
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 60000, staleTime: 60000,
}) })
@ -137,21 +137,21 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
queryKey: ['dispute', disputeId], queryKey: ['dispute', disputeId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch dispute') if (!res.ok) throw new Error('Failed to fetch dispute')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => { mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => {
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
@ -174,8 +174,8 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
mutationFn: async () => { mutationFn: async () => {
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
}, },
}) })
if (!res.ok) { if (!res.ok) {
@ -253,7 +253,7 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
try { try {
const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, { const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json() const err = await res.json()
@ -292,8 +292,8 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, { const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
}, },
body: formData, body: formData,
}) })

View file

@ -60,12 +60,12 @@ export default function Disputes() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Build query params // Build query params
@ -85,12 +85,12 @@ export default function Disputes() {
queryFn: async () => { queryFn: async () => {
const url = queryString ? `/kitchen/api/disputes?${queryString}` : '/kitchen/api/disputes' const url = queryString ? `/kitchen/api/disputes?${queryString}` : '/kitchen/api/disputes'
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch disputes') if (!res.ok) throw new Error('Failed to fetch disputes')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Client-side filter for "not_resolved" - show all except resolved // Client-side filter for "not_resolved" - show all except resolved

File diff suppressed because it is too large Load diff

View file

@ -1,189 +1,189 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
interface EventOrderItem { interface EventOrderItem {
id: number id: number
name: string name: string
event_date: string | null event_date: string | null
notes: string | null notes: string | null
status: string status: string
item_count: number item_count: number
estimated_cost: number | null estimated_cost: number | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
export default function EventOrders() { export default function EventOrders() {
const { token } = useAuth() const { token } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [formName, setFormName] = useState('') const [formName, setFormName] = useState('')
const [formDate, setFormDate] = useState('') const [formDate, setFormDate] = useState('')
const [formNotes, setFormNotes] = useState('') const [formNotes, setFormNotes] = useState('')
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({ const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
queryKey: ['event-orders'], queryKey: ['event-orders'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/event-orders', { const res = await fetch('/kitchen/api/event-orders', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => { mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
const res = await fetch('/kitchen/api/event-orders', { const res = await fetch('/kitchen/api/event-orders', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to create') if (!res.ok) throw new Error('Failed to create')
return res.json() return res.json()
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['event-orders'] }) queryClient.invalidateQueries({ queryKey: ['event-orders'] })
setShowCreate(false) setShowCreate(false)
navigate(`/event-orders/${data.id}`) navigate(`/event-orders/${data.id}`)
}, },
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/event-orders/${id}`, { const res = await fetch(`/kitchen/api/event-orders/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
}) })
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
DRAFT: '#f59e0b', DRAFT: '#f59e0b',
FINALISED: '#3b82f6', FINALISED: '#3b82f6',
ORDERED: '#22c55e', ORDERED: '#22c55e',
} }
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={styles.header}> <div style={styles.header}>
<h2 style={{ margin: 0 }}>Event Orders</h2> <h2 style={{ margin: 0 }}>Event Orders</h2>
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}> <button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
+ New Event Order + New Event Order
</button> </button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div style={styles.loading}>Loading...</div> <div style={styles.loading}>Loading...</div>
) : orders && orders.length > 0 ? ( ) : orders && orders.length > 0 ? (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Name</th> <th style={styles.th}>Name</th>
<th style={styles.th}>Date</th> <th style={styles.th}>Date</th>
<th style={styles.th}>Status</th> <th style={styles.th}>Status</th>
<th style={styles.th}>Recipes</th> <th style={styles.th}>Recipes</th>
<th style={styles.th}>Actions</th> <th style={styles.th}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map(o => ( {orders.map(o => (
<tr key={o.id} style={styles.tr} onClick={() => navigate(`/event-orders/${o.id}`)} > <tr key={o.id} style={styles.tr} onClick={() => navigate(`/event-orders/${o.id}`)} >
<td style={{ ...styles.td, fontWeight: 500, cursor: 'pointer' }}>{o.name}</td> <td style={{ ...styles.td, fontWeight: 500, cursor: 'pointer' }}>{o.name}</td>
<td style={styles.td}>{o.event_date || '-'}</td> <td style={styles.td}>{o.event_date || '-'}</td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ <span style={{
background: statusColors[o.status] || '#ccc', background: statusColors[o.status] || '#ccc',
color: 'white', color: 'white',
padding: '2px 8px', padding: '2px 8px',
borderRadius: '4px', borderRadius: '4px',
fontSize: '0.75rem', fontSize: '0.75rem',
fontWeight: 600, fontWeight: 600,
}}> }}>
{o.status} {o.status}
</span> </span>
</td> </td>
<td style={styles.td}>{o.item_count}</td> <td style={styles.td}>{o.item_count}</td>
<td style={styles.td}> <td style={styles.td}>
{o.status === 'DRAFT' && ( {o.status === 'DRAFT' && (
<button <button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }} onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
style={{ ...styles.smallBtn, color: '#e94560' }} style={{ ...styles.smallBtn, color: '#e94560' }}
> >
Delete Delete
</button> </button>
)} )}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}> <div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
No event orders yet. Create one to start planning. No event orders yet. Create one to start planning.
</div> </div>
)} )}
{showCreate && ( {showCreate && (
<div style={styles.overlay}> <div style={styles.overlay}>
<div style={styles.modal}> <div style={styles.modal}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>New Event Order</h3> <h3 style={{ margin: 0 }}>New Event Order</h3>
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}></button> <button onClick={() => setShowCreate(false)} style={styles.closeBtn}></button>
</div> </div>
<div style={styles.modalBody}> <div style={styles.modalBody}>
<label style={styles.label}>Event Name *</label> <label style={styles.label}>Event Name *</label>
<input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Wedding Reception 15th March" /> <input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Wedding Reception 15th March" />
<label style={styles.label}>Event Date</label> <label style={styles.label}>Event Date</label>
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} /> <input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
<label style={styles.label}>Notes</label> <label style={styles.label}>Notes</label>
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} /> <textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
</div> </div>
<div style={styles.modalFooter}> <div style={styles.modalFooter}>
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button> <button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
<button <button
onClick={() => createMutation.mutate({ onClick={() => createMutation.mutate({
name: formName, name: formName,
event_date: formDate || undefined, event_date: formDate || undefined,
notes: formNotes || undefined, notes: formNotes || undefined,
})} })}
disabled={!formName || createMutation.isPending} disabled={!formName || createMutation.isPending}
style={styles.primaryBtn} style={styles.primaryBtn}
> >
Create Create
</button> </button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' }, page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }, 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.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' }, th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
tr: { borderBottom: '1px solid #f0f0f0', cursor: 'pointer' }, tr: { borderBottom: '1px solid #f0f0f0', cursor: 'pointer' },
td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem' }, td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem' },
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' }, smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }, primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' }, cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' }, loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: 'white', borderRadius: '10px', width: '450px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' }, modal: { background: 'white', borderRadius: '10px', width: '450px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' }, modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
modalBody: { padding: '1.25rem' }, modalBody: { padding: '1.25rem' },
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' }, modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' }, closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' }, label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const }, input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
} }

View file

@ -158,14 +158,22 @@ export default function GPReport() {
const monthOptions = getMonthOptions() const monthOptions = getMonthOptions()
// Allowances checkbox state - default: all checked EXCEPT wastage // Allowances checkbox state - default: all checked EXCEPT wastage
const [allowancesSelection, setAllowancesSelection] = useState({ const _defaultAllowances = {
wastage: false, // Wastage: unchecked by default wastage: false,
transfer: true, // Transfer: checked by default transfer: true,
staffFood: true, // Staff Food: checked by default staffFood: true,
manualAdjustment: true, // Manual Adjustment: checked by default manualAdjustment: true,
disputes: true, // Open Disputes: checked by default disputes: true,
cdDeductions: true, // Distributed Deductions: checked by default cdDeductions: true,
cdReallocations: true // Distributed Reallocations: checked by default cdReallocations: true,
}
const [allowancesSelection, setAllowancesSelection] = useState(() => {
try {
const stored = localStorage.getItem('gpreport_allowances_v1')
return stored ? { ..._defaultAllowances, ...JSON.parse(stored) } : _defaultAllowances
} catch {
return _defaultAllowances
}
}) })
// Track if dates have changed since last generation // Track if dates have changed since last generation
@ -318,7 +326,7 @@ export default function GPReport() {
queryKey: ['gp-range', submittedFromDate, submittedToDate], queryKey: ['gp-range', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/gp/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch GP data') if (!res.ok) throw new Error('Failed to fetch GP data')
return res.json() return res.json()
@ -332,7 +340,7 @@ export default function GPReport() {
queryKey: ['gp-daily', submittedFromDate, submittedToDate], queryKey: ['gp-daily', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch chart data') if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json() return res.json()
@ -346,7 +354,7 @@ export default function GPReport() {
queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate], queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/top-sellers?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/gp/top-sellers?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
// Don't throw for top sellers - just return empty data // Don't throw for top sellers - just return empty data
@ -554,12 +562,62 @@ export default function GPReport() {
// Calculate GP with selected allowances + CD adjustments // Calculate GP with selected allowances + CD adjustments
const gpWithSelectedAllowances = salesNum > 0 const gpWithSelectedAllowances = salesNum > 0
? ((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100) ? Math.min((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100, 100)
: 0 : 0
const downloadCSV = () => {
if (!data) return
const rows: string[][] = [
['Kitchen Flash GP Report'],
['Period', period_label],
[],
['Metric', 'Value'],
['Net Food Sales', net_food_sales.toString()],
['Net Food Purchases', net_food_purchases.toString()],
['Gross Profit', gross_profit.toString()],
['Gross Profit %', Number(gross_profit_percent).toFixed(2)],
]
if (hasAnyAllowancesData) {
rows.push([])
rows.push(['Adjustments', ''])
if (hasWastage) rows.push(['Wastage', (wastage_total ?? 0).toString()])
if (hasTransfer) rows.push(['Transfers', (transfer_total ?? 0).toString()])
if (hasStaffFood) rows.push(['Staff Food', (staff_food_total ?? 0).toString()])
if (hasManualAdjustment) rows.push(['Manual Adjustments', (manual_adjustment_total ?? 0).toString()])
if (hasDisputes) rows.push(['Disputes', (disputes_total ?? 0).toString()])
rows.push(['GP with Adjustments %', gpWithSelectedAllowances.toFixed(2)])
}
if (data.supplier_breakdown?.length) {
rows.push([])
rows.push(['Supplier', 'Net Purchases', '% of Total'])
data.supplier_breakdown.forEach(s => {
rows.push([s.supplier_name, s.net_purchases.toString(), s.percentage.toFixed(1)])
})
}
if (chartData?.data?.length) {
rows.push([])
rows.push(['Date', 'Net Sales', 'Net Purchases', 'Covers'])
chartData.data.forEach(d => {
rows.push([d.date, d.net_sales.toString(), d.net_purchases.toString(), (d.total_covers ?? '').toString()])
})
}
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `kitchen-flash-${submittedFromDate}-to-${submittedToDate}.csv`
a.click()
URL.revokeObjectURL(url)
}
// Toggle checkbox handler // Toggle checkbox handler
const toggleAllowance = (key: keyof typeof allowancesSelection) => { const toggleAllowance = (key: keyof typeof allowancesSelection) => {
setAllowancesSelection(prev => ({ ...prev, [key]: !prev[key] })) setAllowancesSelection(prev => {
const next = { ...prev, [key]: !prev[key] }
try { localStorage.setItem('gpreport_allowances_v1', JSON.stringify(next)) } catch {}
return next
})
} }
return ( return (
@ -626,7 +684,14 @@ export default function GPReport() {
</div> </div>
{/* Period Label */} {/* Period Label */}
<div style={styles.periodLabel}>{getPeriodPrefix()}{period_label}</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div style={styles.periodLabel}>{getPeriodPrefix()}{period_label}</div>
{data && (
<button onClick={downloadCSV} style={styles.csvBtn}>
Download CSV
</button>
)}
</div>
{/* Main Content - GP Estimate and Chart side by side */} {/* Main Content - GP Estimate and Chart side by side */}
<div style={styles.mainContent}> <div style={styles.mainContent}>
@ -1252,9 +1317,18 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: '1.1rem', fontSize: '1.1rem',
fontWeight: 'bold', fontWeight: 'bold',
color: '#1a1a2e', color: '#1a1a2e',
marginBottom: '1rem',
textAlign: 'center', textAlign: 'center',
}, },
csvBtn: {
padding: '0.4rem 0.9rem',
fontSize: '0.8rem',
background: 'transparent',
border: '1px solid #1a1a2e',
color: '#1a1a2e',
borderRadius: '6px',
cursor: 'pointer',
whiteSpace: 'nowrap' as const,
},
mainContent: { mainContent: {
display: 'flex', display: 'flex',
gap: '1.5rem', gap: '1.5rem',

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,396 +1,396 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
import FoodFlagBadges from './FoodFlagBadges' import FoodFlagBadges from './FoodFlagBadges'
import IngredientModal from './IngredientModal' import IngredientModal from './IngredientModal'
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers' import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
interface FlagInfo { interface FlagInfo {
id: number id: number
food_flag_id: number food_flag_id: number
flag_name: string flag_name: string
flag_code: string | null flag_code: string | null
category_name: string category_name: string
propagation_type: string propagation_type: string
source: string source: string
} }
interface IngredientItem { interface IngredientItem {
id: number id: number
name: string name: string
category_id: number | null category_id: number | null
category_name: string | null category_name: string | null
standard_unit: string standard_unit: string
yield_percent: number yield_percent: number
manual_price: number | null manual_price: number | null
notes: string | null notes: string | null
is_archived: boolean is_archived: boolean
is_prepackaged: boolean is_prepackaged: boolean
is_free: boolean is_free: boolean
product_ingredients: string | null product_ingredients: string | null
has_label_image: boolean has_label_image: boolean
source_count: number source_count: number
effective_price: number | null effective_price: number | null
flags: FlagInfo[] flags: FlagInfo[]
none_categories: string[] none_categories: string[]
created_at: string created_at: string
} }
interface SourceItem { interface SourceItem {
id: number id: number
supplier_id: number supplier_id: number
supplier_name: string supplier_name: string
product_code: string | null product_code: string | null
description_pattern: string | null description_pattern: string | null
pack_quantity: number | null pack_quantity: number | null
unit_size: number | null unit_size: number | null
unit_size_type: string | null unit_size_type: string | null
latest_unit_price: number | null latest_unit_price: number | null
latest_invoice_date: string | null latest_invoice_date: string | null
price_per_std_unit: number | null price_per_std_unit: number | null
} }
export default function Ingredients() { export default function Ingredients() {
const { token } = useAuth() const { token } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('') const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnmapped, setShowUnmapped] = useState(false) const [showUnmapped, setShowUnmapped] = useState(false)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null) const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
// Categories (for filter dropdown) // Categories (for filter dropdown)
const { data: categories } = useQuery<IngredientCategory[]>({ const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'], queryKey: ['ingredient-categories'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/categories', { const res = await fetch('/kitchen/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch categories') if (!res.ok) throw new Error('Failed to fetch categories')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Ingredients list // Ingredients list
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({ const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived], queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set('search', search) if (search) params.set('search', search)
if (categoryFilter) params.set('category_id', categoryFilter) if (categoryFilter) params.set('category_id', categoryFilter)
if (showUnmapped) params.set('unmapped', 'true') if (showUnmapped) params.set('unmapped', 'true')
if (showArchived) params.set('archived', 'true') if (showArchived) params.set('archived', 'true')
const res = await fetch(`/kitchen/api/ingredients?${params}`, { const res = await fetch(`/kitchen/api/ingredients?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch ingredients') if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Sources for expanded ingredient // Sources for expanded ingredient
const { data: sources } = useQuery<SourceItem[]>({ const { data: sources } = useQuery<SourceItem[]>({
queryKey: ['ingredient-sources', expandedId], queryKey: ['ingredient-sources', expandedId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, { const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch sources') if (!res.ok) throw new Error('Failed to fetch sources')
return res.json() return res.json()
}, },
enabled: !!token && !!expandedId, enabled: !!token && !!expandedId,
}) })
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, { const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to archive') if (!res.ok) throw new Error('Failed to archive')
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
}) })
const unarchiveMutation = useMutation({ const unarchiveMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, { const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }), body: JSON.stringify({ is_archived: false }),
}) })
if (!res.ok) throw new Error('Failed to unarchive') if (!res.ok) throw new Error('Failed to unarchive')
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
}) })
const startEdit = (ing: IngredientItem) => { const startEdit = (ing: IngredientItem) => {
setEditingIngredient({ setEditingIngredient({
id: ing.id, id: ing.id,
name: ing.name, name: ing.name,
category_id: ing.category_id, category_id: ing.category_id,
standard_unit: ing.standard_unit, standard_unit: ing.standard_unit,
yield_percent: ing.yield_percent, yield_percent: ing.yield_percent,
manual_price: ing.manual_price, manual_price: ing.manual_price,
notes: ing.notes, notes: ing.notes,
is_prepackaged: ing.is_prepackaged, is_prepackaged: ing.is_prepackaged,
is_free: ing.is_free, is_free: ing.is_free,
product_ingredients: ing.product_ingredients, product_ingredients: ing.product_ingredients,
has_label_image: ing.has_label_image, has_label_image: ing.has_label_image,
}) })
setShowModal(true) setShowModal(true)
} }
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={styles.header}> <div style={styles.header}>
<h2 style={{ margin: 0 }}>Ingredient Library</h2> <h2 style={{ margin: 0 }}>Ingredient Library</h2>
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}> <button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
+ Create Ingredient + Create Ingredient
</button> </button>
</div> </div>
{/* Filters */} {/* Filters */}
<div style={styles.filterBar}> <div style={styles.filterBar}>
<input <input
type="text" type="text"
placeholder="Search ingredients..." placeholder="Search ingredients..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput} style={styles.searchInput}
/> />
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}> <select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option> <option value="">All Categories</option>
{categories?.map(c => ( {categories?.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option> <option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
))} ))}
</select> </select>
<label style={styles.checkLabel}> <label style={styles.checkLabel}>
<input <input
type="checkbox" type="checkbox"
checked={showUnmapped} checked={showUnmapped}
onChange={(e) => setShowUnmapped(e.target.checked)} onChange={(e) => setShowUnmapped(e.target.checked)}
/> />
Unmapped only Unmapped only
</label> </label>
<label style={styles.checkLabel}> <label style={styles.checkLabel}>
<input <input
type="checkbox" type="checkbox"
checked={showArchived} checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)} onChange={(e) => setShowArchived(e.target.checked)}
/> />
Show Archived Show Archived
</label> </label>
</div> </div>
{/* Stats */} {/* Stats */}
<div style={styles.statsBar}> <div style={styles.statsBar}>
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span> <span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
<span style={{ color: '#888' }}>|</span> <span style={{ color: '#888' }}>|</span>
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span> <span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
</div> </div>
{/* Table */} {/* Table */}
{isLoading ? ( {isLoading ? (
<div style={styles.loading}>Loading ingredients...</div> <div style={styles.loading}>Loading ingredients...</div>
) : ( ) : (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Name</th> <th style={styles.th}>Name</th>
<th style={styles.th}>Category</th> <th style={styles.th}>Category</th>
<th style={styles.th}>Unit</th> <th style={styles.th}>Unit</th>
<th style={styles.th}>Yield %</th> <th style={styles.th}>Yield %</th>
<th style={styles.th}>Sources</th> <th style={styles.th}>Sources</th>
<th style={styles.th}>Price/Unit</th> <th style={styles.th}>Price/Unit</th>
<th style={styles.th}>Flags</th> <th style={styles.th}>Flags</th>
<th style={styles.th}>Actions</th> <th style={styles.th}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ingredients?.map(ing => ( {ingredients?.map(ing => (
<> <>
<tr <tr
key={ing.id} key={ing.id}
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }} style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)} onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
> >
<td style={styles.td}> <td style={styles.td}>
<span style={{ fontWeight: 500, ...(ing.is_archived ? { textDecoration: 'line-through' } : {}) }}>{ing.name}</span> <span style={{ fontWeight: 500, ...(ing.is_archived ? { textDecoration: 'line-through' } : {}) }}>{ing.name}</span>
{ing.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>} {ing.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>}
</td> </td>
<td style={styles.td}>{ing.category_name || '-'}</td> <td style={styles.td}>{ing.category_name || '-'}</td>
<td style={styles.td}>{ing.standard_unit}</td> <td style={styles.td}>{ing.standard_unit}</td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}> <span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
{ing.yield_percent}% {ing.yield_percent}%
</span> </span>
</td> </td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ <span style={{
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b', background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
color: 'white', color: 'white',
padding: '2px 8px', padding: '2px 8px',
borderRadius: '10px', borderRadius: '10px',
fontSize: '0.75rem', fontSize: '0.75rem',
fontWeight: 600, fontWeight: 600,
}}> }}>
{ing.source_count} {ing.source_count}
</span> </span>
</td> </td>
<td style={styles.td}> <td style={styles.td}>
{ing.effective_price != null {ing.effective_price != null
? (() => { ? (() => {
const p = ing.effective_price! const p = ing.effective_price!
const u = ing.standard_unit const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg` if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr` if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}` return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})() })()
: <span style={{ color: '#aaa' }}>-</span> : <span style={{ color: '#aaa' }}>-</span>
} }
</td> </td>
<td style={styles.td}> <td style={styles.td}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
<FoodFlagBadges flags={ing.flags.map(f => ({ <FoodFlagBadges flags={ing.flags.map(f => ({
name: f.flag_name, name: f.flag_name,
code: f.flag_code || undefined, code: f.flag_code || undefined,
category_name: f.category_name, category_name: f.category_name,
propagation: f.propagation_type, propagation: f.propagation_type,
}))} /> }))} />
{ing.none_categories?.map(cat => ( {ing.none_categories?.map(cat => (
<span <span
key={cat} key={cat}
title={`${cat}: None apply`} title={`${cat}: None apply`}
style={{ style={{
display: 'inline-block', display: 'inline-block',
borderRadius: '10px', borderRadius: '10px',
color: 'white', color: 'white',
fontWeight: 600, fontWeight: 600,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
lineHeight: 1.4, lineHeight: 1.4,
fontSize: '0.7rem', fontSize: '0.7rem',
padding: '1px 5px', padding: '1px 5px',
background: '#999', background: '#999',
}} }}
> >
{cat.substring(0, 3)}: None {cat.substring(0, 3)}: None
</span> </span>
))} ))}
</div> </div>
</td> </td>
<td style={styles.td}> <td style={styles.td}>
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button> <button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
{ing.is_archived ? ( {ing.is_archived ? (
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#22c55e' }}>Unarchive</button> <button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#22c55e' }}>Unarchive</button>
) : ( ) : (
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#e94560' }}>Archive</button> <button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#e94560' }}>Archive</button>
)} )}
</td> </td>
</tr> </tr>
{expandedId === ing.id && ( {expandedId === ing.id && (
<tr key={`${ing.id}-expanded`}> <tr key={`${ing.id}-expanded`}>
<td colSpan={8} style={styles.expandedTd}> <td colSpan={8} style={styles.expandedTd}>
<div style={styles.sourcesSection}> <div style={styles.sourcesSection}>
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4> <h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
{sources && sources.length > 0 ? ( {sources && sources.length > 0 ? (
<table style={{ ...styles.table, margin: 0 }}> <table style={{ ...styles.table, margin: 0 }}>
<thead> <thead>
<tr> <tr>
<th style={styles.thSmall}>Supplier</th> <th style={styles.thSmall}>Supplier</th>
<th style={styles.thSmall}>Code/Pattern</th> <th style={styles.thSmall}>Code/Pattern</th>
<th style={styles.thSmall}>Pack</th> <th style={styles.thSmall}>Pack</th>
<th style={styles.thSmall}>Last Price</th> <th style={styles.thSmall}>Last Price</th>
<th style={styles.thSmall}>Price/{ing.standard_unit}</th> <th style={styles.thSmall}>Price/{ing.standard_unit}</th>
<th style={styles.thSmall}>Last Invoice</th> <th style={styles.thSmall}>Last Invoice</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sources.map(s => ( {sources.map(s => (
<tr key={s.id}> <tr key={s.id}>
<td style={styles.tdSmall}>{s.supplier_name}</td> <td style={styles.tdSmall}>{s.supplier_name}</td>
<td style={styles.tdSmall}> <td style={styles.tdSmall}>
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? ( {s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
<a <a
href={`https://www.brake.co.uk/p/${s.product_code}`} href={`https://www.brake.co.uk/p/${s.product_code}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'underline' }} style={{ color: '#2563eb', textDecoration: 'underline' }}
title="View on Brakes website" title="View on Brakes website"
> >
{s.product_code} {s.product_code}
</a> </a>
) : ( ) : (
s.product_code || s.description_pattern || '-' s.product_code || s.description_pattern || '-'
)} )}
</td> </td>
<td style={styles.tdSmall}> <td style={styles.tdSmall}>
{s.pack_quantity && s.unit_size {s.pack_quantity && s.unit_size
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}` ? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
: '-' : '-'
} }
</td> </td>
<td style={styles.tdSmall}> <td style={styles.tdSmall}>
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'} {s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
</td> </td>
<td style={styles.tdSmall}> <td style={styles.tdSmall}>
{s.price_per_std_unit != null ? (() => { {s.price_per_std_unit != null ? (() => {
const p = typeof s.price_per_std_unit === 'string' ? parseFloat(s.price_per_std_unit) : s.price_per_std_unit const p = typeof s.price_per_std_unit === 'string' ? parseFloat(s.price_per_std_unit) : s.price_per_std_unit
const u = ing.standard_unit const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg` if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr` if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}` return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})() : '-'} })() : '-'}
</td> </td>
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td> <td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div> <div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
)} )}
</div> </div>
</td> </td>
</tr> </tr>
)} )}
</> </>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
<IngredientModal <IngredientModal
open={showModal} open={showModal}
onClose={() => { setShowModal(false); setEditingIngredient(null) }} onClose={() => { setShowModal(false); setEditingIngredient(null) }}
editingIngredient={editingIngredient} editingIngredient={editingIngredient}
/> />
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' }, page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }, filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' },
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' }, 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' }, select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.9rem', color: '#555' }, checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.9rem', color: '#555' },
statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }, statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' },
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }, table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' }, th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
thSmall: { padding: '0.4rem 0.5rem', textAlign: 'left' as const, borderBottom: '1px solid #e0e0e0', background: '#f5f5f5', fontSize: '0.75rem', fontWeight: 600, color: '#666' }, thSmall: { padding: '0.4rem 0.5rem', textAlign: 'left' as const, borderBottom: '1px solid #e0e0e0', background: '#f5f5f5', fontSize: '0.75rem', fontWeight: 600, color: '#666' },
tr: { borderBottom: '1px solid #f0f0f0' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' }, td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' }, tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' }, expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
sourcesSection: { padding: '0.5rem' }, sourcesSection: { padding: '0.5rem' },
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem', marginRight: '0.25rem' }, smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem', marginRight: '0.25rem' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }, primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
loading: { padding: '2rem', textAlign: 'center' as const, color: '#888' }, loading: { padding: '2rem', textAlign: 'center' as const, color: '#888' },
} }

View file

@ -101,7 +101,7 @@ export default function InvoiceList() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -121,7 +121,7 @@ export default function InvoiceList() {
queryFn: async () => { queryFn: async () => {
const url = queryString ? `/kitchen/api/invoices/?${queryString}` : '/kitchen/api/invoices/' const url = queryString ? `/kitchen/api/invoices/?${queryString}` : '/kitchen/api/invoices/'
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch invoices') if (!res.ok) throw new Error('Failed to fetch invoices')
return res.json() return res.json()
@ -138,7 +138,7 @@ export default function InvoiceList() {
params.set('limit', '20') params.set('limit', '20')
params.set('sort', 'recent') params.set('sort', 'recent')
const res = await fetch(`/kitchen/api/invoices/?${params}`, { const res = await fetch(`/kitchen/api/invoices/?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch completed invoices') if (!res.ok) throw new Error('Failed to fetch completed invoices')
return res.json() return res.json()

View file

@ -88,7 +88,7 @@ export default function LineItemHistoryModal({
if (dateTo) params.set('date_to', dateTo) if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/kitchen/api/search/line-items/history?${params}`, { const res = await fetch(`/kitchen/api/search/line-items/history?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch history') if (!res.ok) throw new Error('Failed to fetch history')
return res.json() return res.json()
@ -101,8 +101,8 @@ export default function LineItemHistoryModal({
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/search/line-items/acknowledge-price', { const res = await fetch('/kitchen/api/search/line-items/acknowledge-price', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -65,7 +65,7 @@ export default function LinkDisputeModal({
queryKey: ['open-disputes', supplierId], queryKey: ['open-disputes', supplierId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/disputes/supplier/${supplierId}/open`, { const res = await fetch(`/kitchen/api/disputes/supplier/${supplierId}/open`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch disputes') if (!res.ok) throw new Error('Failed to fetch disputes')
return res.json() return res.json()
@ -80,9 +80,9 @@ export default function LinkDisputeModal({
const res = await fetch(`/kitchen/api/disputes/${selectedDisputeId}/link-credit-note`, { const res = await fetch(`/kitchen/api/disputes/${selectedDisputeId}/link-credit-note`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
credit_note_invoice_id: creditNoteInvoiceId, credit_note_invoice_id: creditNoteInvoiceId,

View file

@ -1,461 +1,461 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
import { useDebounce, getDefaultDateRange } from '../utils/searchHelpers' import { useDebounce, getDefaultDateRange } from '../utils/searchHelpers'
interface LineItemResult { interface LineItemResult {
product_code: string | null product_code: string | null
description: string | null description: string | null
supplier_id: number | null supplier_id: number | null
supplier_name: string | null supplier_name: string | null
unit: string | null unit: string | null
most_recent_price: number | null most_recent_price: number | null
occurrence_count: number occurrence_count: number
most_recent_invoice_id: number most_recent_invoice_id: number
most_recent_date: string | null most_recent_date: string | null
pack_quantity: number | null pack_quantity: number | null
most_recent_line_item_id: number | null most_recent_line_item_id: number | null
most_recent_line_number: number | null most_recent_line_number: number | null
most_recent_raw_content: string | null most_recent_raw_content: string | null
most_recent_pack_quantity: number | null most_recent_pack_quantity: number | null
most_recent_unit_size: number | null most_recent_unit_size: number | null
most_recent_unit_size_type: string | null most_recent_unit_size_type: string | null
} }
interface SearchResponse { interface SearchResponse {
items: LineItemResult[] items: LineItemResult[]
total_count: number total_count: number
} }
interface Supplier { interface Supplier {
id: number id: number
name: string name: string
} }
interface MapLineItemsModalProps { interface MapLineItemsModalProps {
ingredient: { ingredient: {
id: number id: number
name: string name: string
standard_unit: string standard_unit: string
yield_percent: number yield_percent: number
effective_price: number | null effective_price: number | null
} }
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
} }
// Unit conversion factors (same as Review.tsx) // Unit conversion factors (same as Review.tsx)
const CONVERSIONS: Record<string, Record<string, number>> = { const CONVERSIONS: Record<string, Record<string, number>> = {
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 }, g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 }, ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
each: { each: 1 }, each: { each: 1 },
} }
function calcConversionDisplay( function calcConversionDisplay(
packQty: number, unitSize: number | null, unitSizeType: string, packQty: number, unitSize: number | null, unitSizeType: string,
standardUnit: string, unitPrice: number | null standardUnit: string, unitPrice: number | null
): string { ): string {
if (!unitSize || !unitSizeType) return '' if (!unitSize || !unitSizeType) return ''
const conv = CONVERSIONS[unitSizeType]?.[standardUnit] const conv = CONVERSIONS[unitSizeType]?.[standardUnit]
if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : '' if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : ''
const totalStd = packQty * unitSize * conv const totalStd = packQty * unitSize * conv
const pricePerStd = unitPrice ? (unitPrice / totalStd) : null const pricePerStd = unitPrice ? (unitPrice / totalStd) : null
const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : '' const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : ''
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}` let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}`
if (pricePerStd) { if (pricePerStd) {
display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}` display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}`
if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)` if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)`
else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)` else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)`
} }
return display return display
} }
export default function MapLineItemsModal({ ingredient, onClose, onSaved }: MapLineItemsModalProps) { export default function MapLineItemsModal({ ingredient, onClose, onSaved }: MapLineItemsModalProps) {
const { token } = useAuth() const { token } = useAuth()
const defaultDates = useMemo(() => getDefaultDateRange(), []) const defaultDates = useMemo(() => getDefaultDateRange(), [])
// Search state // Search state
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [supplierId, setSupplierId] = useState('') const [supplierId, setSupplierId] = useState('')
const [dateFrom] = useState(defaultDates.from) const [dateFrom] = useState(defaultDates.from)
const [dateTo] = useState(defaultDates.to) const [dateTo] = useState(defaultDates.to)
const debouncedSearch = useDebounce(searchInput, 300) const debouncedSearch = useDebounce(searchInput, 300)
// Selected item + pack config // Selected item + pack config
const [selectedItem, setSelectedItem] = useState<LineItemResult | null>(null) const [selectedItem, setSelectedItem] = useState<LineItemResult | null>(null)
const [packQty, setPackQty] = useState(1) const [packQty, setPackQty] = useState(1)
const [unitSize, setUnitSize] = useState<string>('') const [unitSize, setUnitSize] = useState<string>('')
const [unitSizeType, setUnitSizeType] = useState(ingredient.standard_unit) const [unitSizeType, setUnitSizeType] = useState(ingredient.standard_unit)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('') const [successMsg, setSuccessMsg] = useState('')
const [previewError, setPreviewError] = useState(false) const [previewError, setPreviewError] = useState(false)
// Suppliers // Suppliers
const { data: suppliers } = useQuery<Supplier[]>({ const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { headers: { Authorization: `Bearer ${token}` } }) const res = await fetch('/kitchen/api/suppliers/', { credentials: 'include' })
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.suppliers || data || [] return data.suppliers || data || []
}, },
}) })
// Search results // Search results
const { data: searchData, isLoading } = useQuery<SearchResponse>({ const { data: searchData, isLoading } = useQuery<SearchResponse>({
queryKey: ['map-line-items-search', debouncedSearch, supplierId, dateFrom, dateTo], queryKey: ['map-line-items-search', debouncedSearch, supplierId, dateFrom, dateTo],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (debouncedSearch) params.set('q', debouncedSearch) if (debouncedSearch) params.set('q', debouncedSearch)
if (supplierId) params.set('supplier_id', supplierId) if (supplierId) params.set('supplier_id', supplierId)
params.set('date_from', dateFrom) params.set('date_from', dateFrom)
params.set('date_to', dateTo) params.set('date_to', dateTo)
params.set('limit', '50') params.set('limit', '50')
const res = await fetch(`/kitchen/api/search/line-items?${params}`, { const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { items: [], total_count: 0 } if (!res.ok) return { items: [], total_count: 0 }
return res.json() return res.json()
}, },
enabled: debouncedSearch.length >= 2, enabled: debouncedSearch.length >= 2,
}) })
// When selecting a line item, auto-fill pack fields from DB values first // When selecting a line item, auto-fill pack fields from DB values first
const handleSelect = (item: LineItemResult) => { const handleSelect = (item: LineItemResult) => {
setSelectedItem(item) setSelectedItem(item)
setPreviewError(false) setPreviewError(false)
setError('') setError('')
setSuccessMsg('') setSuccessMsg('')
// Prefer DB-stored pack values from the most recent line item // Prefer DB-stored pack values from the most recent line item
if (item.most_recent_pack_quantity && item.most_recent_unit_size) { if (item.most_recent_pack_quantity && item.most_recent_unit_size) {
setPackQty(item.most_recent_pack_quantity) setPackQty(item.most_recent_pack_quantity)
setUnitSize(Number(item.most_recent_unit_size).toString()) setUnitSize(Number(item.most_recent_unit_size).toString())
setUnitSizeType(item.most_recent_unit_size_type || ingredient.standard_unit) setUnitSizeType(item.most_recent_unit_size_type || ingredient.standard_unit)
} else { } else {
// Reset — useEffect regex fallback will attempt to parse from description // Reset — useEffect regex fallback will attempt to parse from description
setPackQty(1) setPackQty(1)
setUnitSize('') setUnitSize('')
setUnitSizeType(ingredient.standard_unit) setUnitSizeType(ingredient.standard_unit)
} }
} }
// Conversion display // Conversion display
const conversionDisplay = useMemo(() => { const conversionDisplay = useMemo(() => {
const us = parseFloat(unitSize) const us = parseFloat(unitSize)
if (!us || !unitSizeType) return '' if (!us || !unitSizeType) return ''
const price = selectedItem?.most_recent_price != null ? Number(selectedItem.most_recent_price) : null const price = selectedItem?.most_recent_price != null ? Number(selectedItem.most_recent_price) : null
return calcConversionDisplay(packQty, us, unitSizeType, ingredient.standard_unit, price) return calcConversionDisplay(packQty, us, unitSizeType, ingredient.standard_unit, price)
}, [packQty, unitSize, unitSizeType, ingredient.standard_unit, selectedItem?.most_recent_price]) }, [packQty, unitSize, unitSizeType, ingredient.standard_unit, selectedItem?.most_recent_price])
// Fallback: auto-fill unitSize from description via client-side regex (only if DB had no pack data) // Fallback: auto-fill unitSize from description via client-side regex (only if DB had no pack data)
useEffect(() => { useEffect(() => {
if (!selectedItem) return if (!selectedItem) return
// Skip if we already populated from DB values // Skip if we already populated from DB values
if (selectedItem.most_recent_pack_quantity && selectedItem.most_recent_unit_size) return if (selectedItem.most_recent_pack_quantity && selectedItem.most_recent_unit_size) return
const desc = selectedItem.description || '' const desc = selectedItem.description || ''
const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i) const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i)
if (packMatch) { if (packMatch) {
setPackQty(parseInt(packMatch[1])) setPackQty(parseInt(packMatch[1]))
setUnitSize(packMatch[2]) setUnitSize(packMatch[2])
let ut = packMatch[3].toLowerCase() let ut = packMatch[3].toLowerCase()
if (ut === 'l') ut = 'ltr' if (ut === 'l') ut = 'ltr'
if (ut === 'gm' || ut === 'gms') ut = 'g' if (ut === 'gm' || ut === 'gms') ut = 'g'
setUnitSizeType(ut) setUnitSizeType(ut)
return return
} }
const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i) const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i)
if (standaloneMatch) { if (standaloneMatch) {
setPackQty(1) setPackQty(1)
setUnitSize(standaloneMatch[1]) setUnitSize(standaloneMatch[1])
let ut = standaloneMatch[2].toLowerCase() let ut = standaloneMatch[2].toLowerCase()
if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr' if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr'
if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g' if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g'
if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg' if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg'
setUnitSizeType(ut) setUnitSizeType(ut)
} }
}, [selectedItem]) }, [selectedItem])
const handleSave = async () => { const handleSave = async () => {
if (!selectedItem) return if (!selectedItem) return
setSaving(true) setSaving(true)
setError('') setError('')
setSuccessMsg('') setSuccessMsg('')
try { try {
const sourceData: Record<string, unknown> = { const sourceData: Record<string, unknown> = {
supplier_id: selectedItem.supplier_id, supplier_id: selectedItem.supplier_id,
pack_quantity: packQty || 1, pack_quantity: packQty || 1,
unit_size: parseFloat(unitSize) || null, unit_size: parseFloat(unitSize) || null,
unit_size_type: unitSizeType || null, unit_size_type: unitSizeType || null,
apply_to_existing: true, apply_to_existing: true,
} }
if (selectedItem.product_code) { if (selectedItem.product_code) {
sourceData.product_code = selectedItem.product_code sourceData.product_code = selectedItem.product_code
} else if (selectedItem.description) { } else if (selectedItem.description) {
sourceData.description_pattern = selectedItem.description.substring(0, 100).toLowerCase().trim() sourceData.description_pattern = selectedItem.description.substring(0, 100).toLowerCase().trim()
} }
if (selectedItem.most_recent_price) { if (selectedItem.most_recent_price) {
sourceData.latest_unit_price = selectedItem.most_recent_price sourceData.latest_unit_price = selectedItem.most_recent_price
} }
if (selectedItem.most_recent_invoice_id) { if (selectedItem.most_recent_invoice_id) {
sourceData.invoice_id = selectedItem.most_recent_invoice_id sourceData.invoice_id = selectedItem.most_recent_invoice_id
} }
const res = await fetch(`/kitchen/api/ingredients/${ingredient.id}/sources`, { const res = await fetch(`/kitchen/api/ingredients/${ingredient.id}/sources`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData), body: JSON.stringify(sourceData),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const count = data.matched_line_items const count = data.matched_line_items
setSuccessMsg(`Source created${count ? ` \u2014 ${count} line item${count > 1 ? 's' : ''} mapped` : ''}`) setSuccessMsg(`Source created${count ? ` \u2014 ${count} line item${count > 1 ? 's' : ''} mapped` : ''}`)
setTimeout(() => onSaved(), 1200) setTimeout(() => onSaved(), 1200)
} else if (res.status === 409) { } else if (res.status === 409) {
setError('This supplier product is already mapped to this ingredient') setError('This supplier product is already mapped to this ingredient')
} else { } else {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
setError(body.detail || 'Failed to create source') setError(body.detail || 'Failed to create source')
} }
} catch (err) { } catch (err) {
setError('Network error') setError('Network error')
console.error(err) console.error(err)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
// Preview URL for the selected line item // Preview URL for the selected line item
const previewUrl = selectedItem?.most_recent_invoice_id && selectedItem?.most_recent_line_number != null const previewUrl = selectedItem?.most_recent_invoice_id && selectedItem?.most_recent_line_number != null
? `/kitchen/api/invoices/${selectedItem.most_recent_invoice_id}/line-items/${selectedItem.most_recent_line_number}/preview?token=${token}` ? `/kitchen/api/invoices/${selectedItem.most_recent_invoice_id}/line-items/${selectedItem.most_recent_line_number}/preview`
: null : null
return ( return (
<div style={styles.overlay} onClick={onClose}> <div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}> <div style={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div style={styles.header}> <div style={styles.header}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Map Line Items to "{ingredient.name}"</h3> <h3 style={{ margin: 0, fontSize: '1rem' }}>Map Line Items to "{ingredient.name}"</h3>
<button onClick={onClose} style={styles.closeBtn}>{'\u2715'}</button> <button onClick={onClose} style={styles.closeBtn}>{'\u2715'}</button>
</div> </div>
{/* Context bar */} {/* Context bar */}
<div style={styles.contextBar}> <div style={styles.contextBar}>
<span><strong>{ingredient.name}</strong></span> <span><strong>{ingredient.name}</strong></span>
<span>Unit: {ingredient.standard_unit}</span> <span>Unit: {ingredient.standard_unit}</span>
{ingredient.effective_price != null && ( {ingredient.effective_price != null && (
<span>Current: {'\u00a3'}{Number(ingredient.effective_price).toFixed(4)}/{ingredient.standard_unit}</span> <span>Current: {'\u00a3'}{Number(ingredient.effective_price).toFixed(4)}/{ingredient.standard_unit}</span>
)} )}
</div> </div>
<div style={styles.body}> <div style={styles.body}>
{/* Search controls */} {/* Search controls */}
<div style={styles.searchRow}> <div style={styles.searchRow}>
<input <input
value={searchInput} value={searchInput}
onChange={(e) => { setSearchInput(e.target.value); setSelectedItem(null); setError(''); setSuccessMsg('') }} onChange={(e) => { setSearchInput(e.target.value); setSelectedItem(null); setError(''); setSuccessMsg('') }}
style={{ ...styles.input, flex: 2 }} style={{ ...styles.input, flex: 2 }}
placeholder="Search by product code or description..." placeholder="Search by product code or description..."
autoFocus autoFocus
/> />
<select <select
value={supplierId} value={supplierId}
onChange={(e) => setSupplierId(e.target.value)} onChange={(e) => setSupplierId(e.target.value)}
style={{ ...styles.input, flex: 1, minWidth: '140px' }} style={{ ...styles.input, flex: 1, minWidth: '140px' }}
> >
<option value="">All Suppliers</option> <option value="">All Suppliers</option>
{suppliers?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)} {suppliers?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select> </select>
</div> </div>
{/* Results table */} {/* Results table */}
{debouncedSearch.length >= 2 && ( {debouncedSearch.length >= 2 && (
<div style={styles.resultsContainer}> <div style={styles.resultsContainer}>
{isLoading ? ( {isLoading ? (
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>Searching...</div> <div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>Searching...</div>
) : !searchData?.items.length ? ( ) : !searchData?.items.length ? (
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>No line items found</div> <div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>No line items found</div>
) : ( ) : (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Description</th> <th style={styles.th}>Description</th>
<th style={styles.th}>Supplier</th> <th style={styles.th}>Supplier</th>
<th style={styles.th}>Price</th> <th style={styles.th}>Price</th>
<th style={styles.th}>#</th> <th style={styles.th}>#</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{searchData.items.map((item, idx) => { {searchData.items.map((item, idx) => {
const isSelected = selectedItem === item const isSelected = selectedItem === item
return ( return (
<tr <tr
key={`${item.product_code || ''}-${item.supplier_id}-${idx}`} key={`${item.product_code || ''}-${item.supplier_id}-${idx}`}
style={{ style={{
...styles.tr, ...styles.tr,
background: isSelected ? '#e8f5e9' : undefined, background: isSelected ? '#e8f5e9' : undefined,
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
<td style={styles.td}> <td style={styles.td}>
{item.product_code && ( {item.product_code && (
<span style={{ color: '#888', fontSize: '0.75rem', marginRight: '0.35rem' }}>{item.product_code}</span> <span style={{ color: '#888', fontSize: '0.75rem', marginRight: '0.35rem' }}>{item.product_code}</span>
)} )}
{item.description} {item.description}
</td> </td>
<td style={styles.td}>{item.supplier_name || '-'}</td> <td style={styles.td}>{item.supplier_name || '-'}</td>
<td style={styles.td}> <td style={styles.td}>
{item.most_recent_price != null ? `\u00a3${Number(item.most_recent_price).toFixed(2)}` : '-'} {item.most_recent_price != null ? `\u00a3${Number(item.most_recent_price).toFixed(2)}` : '-'}
</td> </td>
<td style={styles.td}>{item.occurrence_count}</td> <td style={styles.td}>{item.occurrence_count}</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
)} )}
{/* OCR bounding box preview */} {/* OCR bounding box preview */}
{selectedItem && previewUrl && !previewError && ( {selectedItem && previewUrl && !previewError && (
<div style={styles.previewContainer}> <div style={styles.previewContainer}>
<img <img
src={previewUrl} src={previewUrl}
alt="Line item preview" alt="Line item preview"
style={styles.previewImage} style={styles.previewImage}
onError={() => setPreviewError(true)} onError={() => setPreviewError(true)}
/> />
</div> </div>
)} )}
{/* Pack config - shown when a line item is selected */} {/* Pack config - shown when a line item is selected */}
{selectedItem && ( {selectedItem && (
<div style={styles.packSection}> <div style={styles.packSection}>
<label style={styles.label}> <label style={styles.label}>
How much {ingredient.name} is in "{selectedItem.description}"? How much {ingredient.name} is in "{selectedItem.description}"?
</label> </label>
<div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}> <div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
{selectedItem.supplier_name} {selectedItem.supplier_name}
{selectedItem.most_recent_price != null ? ` \u2014 \u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : ''} {selectedItem.most_recent_price != null ? ` \u2014 \u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : ''}
</div> </div>
{/* Raw OCR content for context */} {/* Raw OCR content for context */}
{selectedItem.most_recent_raw_content && selectedItem.most_recent_raw_content !== selectedItem.description && ( {selectedItem.most_recent_raw_content && selectedItem.most_recent_raw_content !== selectedItem.description && (
<div style={styles.rawContent}> <div style={styles.rawContent}>
{selectedItem.most_recent_raw_content} {selectedItem.most_recent_raw_content}
</div> </div>
)} )}
<div style={styles.packRow}> <div style={styles.packRow}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={styles.packLabel}>Contains</div> <div style={styles.packLabel}>Contains</div>
<input <input
type="number" type="number"
value={unitSize} value={unitSize}
onChange={(e) => setUnitSize(e.target.value)} onChange={(e) => setUnitSize(e.target.value)}
style={styles.input} style={styles.input}
step="0.1" step="0.1"
min="0" min="0"
placeholder="Size" placeholder="Size"
/> />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={styles.packLabel}>Unit</div> <div style={styles.packLabel}>Unit</div>
<select <select
value={unitSizeType} value={unitSizeType}
onChange={(e) => setUnitSizeType(e.target.value)} onChange={(e) => setUnitSizeType(e.target.value)}
style={styles.input} style={styles.input}
> >
<option value="each">each</option> <option value="each">each</option>
<option value="g">g</option> <option value="g">g</option>
<option value="kg">kg</option> <option value="kg">kg</option>
<option value="ml">ml</option> <option value="ml">ml</option>
<option value="ltr">ltr</option> <option value="ltr">ltr</option>
<option value="oz">oz</option> <option value="oz">oz</option>
<option value="cl">cl</option> <option value="cl">cl</option>
</select> </select>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={styles.packLabel}>Pack of</div> <div style={styles.packLabel}>Pack of</div>
<input <input
type="number" type="number"
value={packQty} value={packQty}
onChange={(e) => setPackQty(parseInt(e.target.value) || 1)} onChange={(e) => setPackQty(parseInt(e.target.value) || 1)}
style={styles.input} style={styles.input}
min="1" min="1"
step="1" step="1"
/> />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={styles.packLabel}>Line Price</div> <div style={styles.packLabel}>Line Price</div>
<div style={{ ...styles.input, background: '#f5f5f5', display: 'flex', alignItems: 'center' }}> <div style={{ ...styles.input, background: '#f5f5f5', display: 'flex', alignItems: 'center' }}>
{selectedItem.most_recent_price != null ? `\u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : '--'} {selectedItem.most_recent_price != null ? `\u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : '--'}
</div> </div>
</div> </div>
</div> </div>
{conversionDisplay && ( {conversionDisplay && (
<div style={styles.conversionBar}> <div style={styles.conversionBar}>
{conversionDisplay} {conversionDisplay}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Messages */} {/* Messages */}
{error && <div style={styles.errorMsg}>{error}</div>} {error && <div style={styles.errorMsg}>{error}</div>}
{successMsg && <div style={styles.successMsg}>{successMsg}</div>} {successMsg && <div style={styles.successMsg}>{successMsg}</div>}
</div> </div>
{/* Footer */} {/* Footer */}
<div style={styles.footer}> <div style={styles.footer}>
<button onClick={onClose} style={styles.cancelBtn}>Cancel</button> <button onClick={onClose} style={styles.cancelBtn}>Cancel</button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!selectedItem || saving} disabled={!selectedItem || saving}
style={{ ...styles.primaryBtn, opacity: !selectedItem || saving ? 0.5 : 1 }} style={{ ...styles.primaryBtn, opacity: !selectedItem || saving ? 0.5 : 1 }}
> >
{saving ? 'Saving...' : 'Save & Map'} {saving ? 'Saving...' : 'Save & Map'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: 'white', borderRadius: '10px', width: '750px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.2)', display: 'flex', flexDirection: 'column' }, modal: { background: 'white', borderRadius: '10px', width: '750px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.2)', display: 'flex', flexDirection: 'column' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
contextBar: { display: 'flex', gap: '1rem', padding: '0.5rem 1.25rem', background: '#f8f9fa', borderBottom: '1px solid #eee', fontSize: '0.8rem', color: '#555', flexWrap: 'wrap' }, contextBar: { display: 'flex', gap: '1rem', padding: '0.5rem 1.25rem', background: '#f8f9fa', borderBottom: '1px solid #eee', fontSize: '0.8rem', color: '#555', flexWrap: 'wrap' },
body: { padding: '1rem 1.25rem', overflow: 'auto', flex: 1 }, body: { padding: '1rem 1.25rem', overflow: 'auto', flex: 1 },
footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' }, footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' }, closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }, searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' },
input: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem', boxSizing: 'border-box' as const }, input: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem', boxSizing: 'border-box' as const },
label: { fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.25rem' }, label: { fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.25rem' },
resultsContainer: { maxHeight: '220px', overflow: 'auto', border: '1px solid #e0e0e0', borderRadius: '6px', marginBottom: '0.75rem' }, resultsContainer: { maxHeight: '220px', overflow: 'auto', border: '1px solid #e0e0e0', borderRadius: '6px', marginBottom: '0.75rem' },
table: { width: '100%', borderCollapse: 'collapse' }, table: { width: '100%', borderCollapse: 'collapse' },
th: { padding: '0.4rem 0.6rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555', position: 'sticky' as const, top: 0 }, th: { padding: '0.4rem 0.6rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555', position: 'sticky' as const, top: 0 },
tr: { borderBottom: '1px solid #f0f0f0' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.4rem 0.6rem', fontSize: '0.8rem' }, td: { padding: '0.4rem 0.6rem', fontSize: '0.8rem' },
previewContainer: { marginBottom: '0.75rem', borderRadius: '6px', overflow: 'hidden', border: '2px solid #ffc107', boxShadow: '0 0 8px rgba(255, 193, 7, 0.3)' }, previewContainer: { marginBottom: '0.75rem', borderRadius: '6px', overflow: 'hidden', border: '2px solid #ffc107', boxShadow: '0 0 8px rgba(255, 193, 7, 0.3)' },
previewImage: { width: '100%', display: 'block', maxHeight: '80px', objectFit: 'contain' as const, background: '#fff' }, previewImage: { width: '100%', display: 'block', maxHeight: '80px', objectFit: 'contain' as const, background: '#fff' },
rawContent: { fontSize: '0.75rem', color: '#999', fontFamily: 'monospace', marginBottom: '0.5rem', padding: '0.35rem 0.5rem', background: '#f0f0f0', borderRadius: '4px', whiteSpace: 'pre-wrap' as const, lineHeight: 1.3 }, rawContent: { fontSize: '0.75rem', color: '#999', fontFamily: 'monospace', marginBottom: '0.5rem', padding: '0.35rem 0.5rem', background: '#f0f0f0', borderRadius: '4px', whiteSpace: 'pre-wrap' as const, lineHeight: 1.3 },
packSection: { background: '#f8f9fa', padding: '0.75rem', borderRadius: '6px', marginTop: '0.5rem' }, packSection: { background: '#f8f9fa', padding: '0.75rem', borderRadius: '6px', marginTop: '0.5rem' },
packRow: { display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }, packRow: { display: 'flex', gap: '0.5rem', alignItems: 'flex-end' },
packLabel: { fontSize: '0.7rem', fontWeight: 600, color: '#888', marginBottom: '0.2rem' }, packLabel: { fontSize: '0.7rem', fontWeight: 600, color: '#888', marginBottom: '0.2rem' },
conversionBar: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32', fontWeight: 500 }, conversionBar: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32', fontWeight: 500 },
errorMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#fdecea', borderRadius: '6px', fontSize: '0.85rem', color: '#c62828' }, errorMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#fdecea', borderRadius: '6px', fontSize: '0.85rem', color: '#c62828' },
successMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32' }, successMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }, primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' }, cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
} }

View file

@ -85,7 +85,7 @@ export default function MenuEditor() {
queryKey: ['menu', menuId], queryKey: ['menu', menuId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/menus/${menuId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch menu') if (!res.ok) throw new Error('Failed to fetch menu')
return res.json() return res.json()
@ -99,7 +99,7 @@ export default function MenuEditor() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch(`/kitchen/api/menus/${menuId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to update') if (!res.ok) throw new Error('Failed to update')
@ -111,7 +111,7 @@ export default function MenuEditor() {
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions`, { const res = await fetch(`/kitchen/api/menus/${menuId}/divisions`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}) })
if (!res.ok) throw new Error('Failed to add division') if (!res.ok) throw new Error('Failed to add division')
@ -127,7 +127,7 @@ export default function MenuEditor() {
mutationFn: async ({ divId, name }: { divId: number; name: string }) => { mutationFn: async ({ divId, name }: { divId: number; name: string }) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}) })
if (!res.ok) throw new Error('Failed to rename') if (!res.ok) throw new Error('Failed to rename')
@ -142,7 +142,7 @@ export default function MenuEditor() {
mutationFn: async (divId: number) => { mutationFn: async (divId: number) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete') if (!res.ok) throw new Error('Failed to delete')
}, },
@ -153,7 +153,7 @@ export default function MenuEditor() {
mutationFn: async (ids: number[]) => { mutationFn: async (ids: number[]) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/reorder`, { const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/reorder`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}) })
if (!res.ok) throw new Error('Failed to reorder') if (!res.ok) throw new Error('Failed to reorder')
@ -165,7 +165,7 @@ export default function MenuEditor() {
mutationFn: async ({ itemId, data }: { itemId: number; data: Record<string, unknown> }) => { mutationFn: async ({ itemId, data }: { itemId: number; data: Record<string, unknown> }) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to update') if (!res.ok) throw new Error('Failed to update')
@ -180,7 +180,7 @@ export default function MenuEditor() {
mutationFn: async (itemId: number) => { mutationFn: async (itemId: number) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to remove') if (!res.ok) throw new Error('Failed to remove')
}, },
@ -191,7 +191,7 @@ export default function MenuEditor() {
mutationFn: async ({ itemId, confirmed_by_name }: { itemId: number; confirmed_by_name: string }) => { mutationFn: async ({ itemId, confirmed_by_name }: { itemId: number; confirmed_by_name: string }) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/republish`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/republish`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed_by_name }), body: JSON.stringify({ confirmed_by_name }),
}) })
if (!res.ok) throw new Error('Failed to republish') if (!res.ok) throw new Error('Failed to republish')
@ -203,7 +203,7 @@ export default function MenuEditor() {
mutationFn: async (ids: number[]) => { mutationFn: async (ids: number[]) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/items/reorder`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/reorder`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}) })
if (!res.ok) throw new Error('Failed to reorder') if (!res.ok) throw new Error('Failed to reorder')
@ -215,7 +215,7 @@ export default function MenuEditor() {
mutationFn: async (body: { confirmed_by_name: string; items: Array<{ id: number; confirmed: boolean }> }) => { mutationFn: async (body: { confirmed_by_name: string; items: Array<{ id: number; confirmed: boolean }> }) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/republish-stale`, { const res = await fetch(`/kitchen/api/menus/${menuId}/republish-stale`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) throw new Error('Failed to batch republish') if (!res.ok) throw new Error('Failed to batch republish')
@ -233,7 +233,7 @@ export default function MenuEditor() {
formData.append('file', file) formData.append('file', file)
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
body: formData, body: formData,
}) })
if (!res.ok) throw new Error('Failed to upload image') if (!res.ok) throw new Error('Failed to upload image')
@ -245,7 +245,7 @@ export default function MenuEditor() {
mutationFn: async (itemId: number) => { mutationFn: async (itemId: number) => {
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, { const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete image') if (!res.ok) throw new Error('Failed to delete image')
}, },
@ -471,7 +471,7 @@ export default function MenuEditor() {
{/* Image thumbnail */} {/* Image thumbnail */}
{item.has_image ? ( {item.has_image ? (
<img <img
src={`/kitchen/api/menus/${menuId}/items/${item.id}/image?token=${token}`} src={`/kitchen/api/menus/${menuId}/items/${item.id}/image`}
alt="" alt=""
style={styles.thumbnail} style={styles.thumbnail}
/> />

View file

@ -40,12 +40,12 @@ export default function MenuFlagMatrix({ menuId, menuName, onClose }: Props) {
queryKey: ['menu-flag-matrix', menuId], queryKey: ['menu-flag-matrix', menuId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/menus/${menuId}/flags`, { const res = await fetch(`/kitchen/api/menus/${menuId}/flags`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
if (isLoading) return ( if (isLoading) return (

View file

@ -38,12 +38,12 @@ export default function MenuList() {
queryKey: ['menus'], queryKey: ['menus'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/menus', { const res = await fetch('/kitchen/api/menus', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch menus') if (!res.ok) throw new Error('Failed to fetch menus')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 0, staleTime: 0,
}) })
@ -51,7 +51,7 @@ export default function MenuList() {
mutationFn: async (data: { name: string; description: string | null; notes: string | null; preset_divisions: boolean }) => { mutationFn: async (data: { name: string; description: string | null; notes: string | null; preset_divisions: boolean }) => {
const res = await fetch('/kitchen/api/menus', { const res = await fetch('/kitchen/api/menus', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) { if (!res.ok) {
@ -74,7 +74,7 @@ export default function MenuList() {
mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => { mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
const res = await fetch(`/kitchen/api/menus/${id}`, { const res = await fetch(`/kitchen/api/menus/${id}`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active }), body: JSON.stringify({ is_active }),
}) })
if (!res.ok) throw new Error('Failed to update') if (!res.ok) throw new Error('Failed to update')
@ -86,7 +86,7 @@ export default function MenuList() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/menus/${id}`, { const res = await fetch(`/kitchen/api/menus/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete') if (!res.ok) throw new Error('Failed to delete')
}, },
@ -97,7 +97,7 @@ export default function MenuList() {
mutationFn: async ({ id, name }: { id: number; name: string }) => { mutationFn: async ({ id, name }: { id: number; name: string }) => {
const res = await fetch(`/kitchen/api/menus/${id}/duplicate`, { const res = await fetch(`/kitchen/api/menus/${id}/duplicate`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}) })
if (!res.ok) { if (!res.ok) {
@ -118,7 +118,7 @@ export default function MenuList() {
mutationFn: async (ids: number[]) => { mutationFn: async (ids: number[]) => {
const res = await fetch('/kitchen/api/menus/reorder', { const res = await fetch('/kitchen/api/menus/reorder', {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}) })
if (!res.ok) throw new Error('Failed to reorder') if (!res.ok) throw new Error('Failed to reorder')

View file

@ -1,254 +1,254 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../App' import { useAuth } from '../App'
interface IngredientChange { interface IngredientChange {
summary: string summary: string
date: string | null date: string | null
ingredient_name: string | null ingredient_name: string | null
old_price: number | null old_price: number | null
new_price: number | null new_price: number | null
unit: string | null unit: string | null
cost_impact: number | null cost_impact: number | null
source_invoice_id: number | null source_invoice_id: number | null
source_invoice_number: string | null source_invoice_number: string | null
} }
interface ImpactItem { interface ImpactItem {
recipe_id: number recipe_id: number
recipe_name: string recipe_name: string
recipe_type: string recipe_type: string
output_unit: string output_unit: string
current_cost_per_unit: number | null current_cost_per_unit: number | null
previous_cost_per_unit: number | null previous_cost_per_unit: number | null
cost_change: number | null cost_change: number | null
cost_change_pct: number | null cost_change_pct: number | null
ingredient_changes: IngredientChange[] ingredient_changes: IngredientChange[]
} }
function formatIngredientPrice(price: number, unit: string | null): string { function formatIngredientPrice(price: number, unit: string | null): string {
if (unit === 'g' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/kg` if (unit === 'g' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/kg`
if (unit === 'ml' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/ltr` if (unit === 'ml' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/ltr`
return `\u00A3${price.toFixed(4)}/${unit || '?'}` return `\u00A3${price.toFixed(4)}/${unit || '?'}`
} }
interface ImpactData { interface ImpactData {
days: number days: number
recipes: ImpactItem[] recipes: ImpactItem[]
} }
export default function PriceImpact() { export default function PriceImpact() {
const { token } = useAuth() const { token } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [days, setDays] = useState(14) const [days, setDays] = useState(14)
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all') const [typeFilter, setTypeFilter] = useState<string>('all')
const { data, isLoading } = useQuery<ImpactData>({ const { data, isLoading } = useQuery<ImpactData>({
queryKey: ['price-impact', days], queryKey: ['price-impact', days],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/recipes/price-impact?days=${days}`, { const res = await fetch(`/kitchen/api/recipes/price-impact?days=${days}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch price impact') if (!res.ok) throw new Error('Failed to fetch price impact')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const filtered = data?.recipes.filter(r => typeFilter === 'all' || r.recipe_type === typeFilter) || [] const filtered = data?.recipes.filter(r => typeFilter === 'all' || r.recipe_type === typeFilter) || []
const dishCount = data?.recipes.filter(r => r.recipe_type === 'dish').length || 0 const dishCount = data?.recipes.filter(r => r.recipe_type === 'dish').length || 0
const componentCount = data?.recipes.filter(r => r.recipe_type === 'component').length || 0 const componentCount = data?.recipes.filter(r => r.recipe_type === 'component').length || 0
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={styles.header}> <div style={styles.header}>
<h2 style={{ margin: 0 }}>Price Impact Report</h2> <h2 style={{ margin: 0 }}>Price Impact Report</h2>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<label style={{ fontSize: '0.85rem', color: '#666' }}>Period:</label> <label style={{ fontSize: '0.85rem', color: '#666' }}>Period:</label>
<select value={days} onChange={e => setDays(Number(e.target.value))} style={styles.select}> <select value={days} onChange={e => setDays(Number(e.target.value))} style={styles.select}>
<option value={7}>7 days</option> <option value={7}>7 days</option>
<option value={14}>14 days</option> <option value={14}>14 days</option>
<option value={30}>30 days</option> <option value={30}>30 days</option>
<option value={60}>60 days</option> <option value={60}>60 days</option>
<option value={90}>90 days</option> <option value={90}>90 days</option>
</select> </select>
</div> </div>
</div> </div>
{isLoading && <div style={{ padding: '2rem', color: '#888' }}>Loading...</div>} {isLoading && <div style={{ padding: '2rem', color: '#888' }}>Loading...</div>}
{data && ( {data && (
<> <>
{/* Summary stats */} {/* Summary stats */}
<div style={styles.statsBar}> <div style={styles.statsBar}>
<span>{data.recipes.length} recipes affected</span> <span>{data.recipes.length} recipes affected</span>
<span style={{ color: '#888' }}>|</span> <span style={{ color: '#888' }}>|</span>
<span>{dishCount} dishes</span> <span>{dishCount} dishes</span>
<span style={{ color: '#888' }}>|</span> <span style={{ color: '#888' }}>|</span>
<span>{componentCount} components</span> <span>{componentCount} components</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }}>
{(['all', 'dish', 'component'] as const).map(t => ( {(['all', 'dish', 'component'] as const).map(t => (
<button <button
key={t} key={t}
onClick={() => setTypeFilter(t)} onClick={() => setTypeFilter(t)}
style={{ style={{
...styles.filterBtn, ...styles.filterBtn,
...(typeFilter === t ? styles.filterBtnActive : {}), ...(typeFilter === t ? styles.filterBtnActive : {}),
}} }}
> >
{t === 'all' ? 'All' : t === 'dish' ? 'Dishes' : 'Recipes'} {t === 'all' ? 'All' : t === 'dish' ? 'Dishes' : 'Recipes'}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}> <div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
No recipes affected by ingredient price changes in the last {days} days. No recipes affected by ingredient price changes in the last {days} days.
</div> </div>
) : ( ) : (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Recipe</th> <th style={styles.th}>Recipe</th>
<th style={styles.th}>Type</th> <th style={styles.th}>Type</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Previous Cost</th> <th style={{ ...styles.th, textAlign: 'right' }}>Previous Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Current Cost</th> <th style={{ ...styles.th, textAlign: 'right' }}>Current Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Change</th> <th style={{ ...styles.th, textAlign: 'right' }}>Change</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Details</th> <th style={{ ...styles.th, textAlign: 'center' }}>Details</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map(r => ( {filtered.map(r => (
<> <>
<tr key={r.recipe_id} style={styles.tr}> <tr key={r.recipe_id} style={styles.tr}>
<td style={{ ...styles.td, fontWeight: 500 }}> <td style={{ ...styles.td, fontWeight: 500 }}>
<span <span
style={{ cursor: 'pointer', color: '#e94560' }} style={{ cursor: 'pointer', color: '#e94560' }}
onClick={() => navigate(r.recipe_type === 'dish' ? `/dishes/${r.recipe_id}` : `/recipes/${r.recipe_id}`)} onClick={() => navigate(r.recipe_type === 'dish' ? `/dishes/${r.recipe_id}` : `/recipes/${r.recipe_id}`)}
> >
{r.recipe_name} {r.recipe_name}
</span> </span>
</td> </td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ <span style={{
background: r.recipe_type === 'dish' ? '#e94560' : '#3b82f6', background: r.recipe_type === 'dish' ? '#e94560' : '#3b82f6',
color: 'white', color: 'white',
padding: '1px 6px', padding: '1px 6px',
borderRadius: '4px', borderRadius: '4px',
fontSize: '0.7rem', fontSize: '0.7rem',
fontWeight: 600, fontWeight: 600,
}}> }}>
{r.recipe_type.toUpperCase()} {r.recipe_type.toUpperCase()}
</span> </span>
</td> </td>
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}> <td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
{r.previous_cost_per_unit != null {r.previous_cost_per_unit != null
? `\u00A3${r.previous_cost_per_unit.toFixed(4)}/${r.output_unit}` ? `\u00A3${r.previous_cost_per_unit.toFixed(4)}/${r.output_unit}`
: '-'} : '-'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}> <td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
{r.current_cost_per_unit != null {r.current_cost_per_unit != null
? `\u00A3${r.current_cost_per_unit.toFixed(4)}/${r.output_unit}` ? `\u00A3${r.current_cost_per_unit.toFixed(4)}/${r.output_unit}`
: '-'} : '-'}
</td> </td>
<td style={{ <td style={{
...styles.td, ...styles.td,
textAlign: 'right', textAlign: 'right',
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: 600, fontWeight: 600,
color: r.cost_change != null ? (r.cost_change > 0 ? '#dc3545' : r.cost_change < 0 ? '#22c55e' : '#888') : '#888', color: r.cost_change != null ? (r.cost_change > 0 ? '#dc3545' : r.cost_change < 0 ? '#22c55e' : '#888') : '#888',
}}> }}>
{r.cost_change != null ? ( {r.cost_change != null ? (
<> <>
{r.cost_change > 0 ? '+' : ''}{`\u00A3${r.cost_change.toFixed(4)}`} {r.cost_change > 0 ? '+' : ''}{`\u00A3${r.cost_change.toFixed(4)}`}
{r.cost_change_pct != null && ( {r.cost_change_pct != null && (
<span style={{ fontSize: '0.75rem', marginLeft: '4px' }}> <span style={{ fontSize: '0.75rem', marginLeft: '4px' }}>
({r.cost_change_pct > 0 ? '+' : ''}{r.cost_change_pct}%) ({r.cost_change_pct > 0 ? '+' : ''}{r.cost_change_pct}%)
</span> </span>
)} )}
</> </>
) : '-'} ) : '-'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'center' }}> <td style={{ ...styles.td, textAlign: 'center' }}>
<button <button
onClick={() => setExpandedId(expandedId === r.recipe_id ? null : r.recipe_id)} onClick={() => setExpandedId(expandedId === r.recipe_id ? null : r.recipe_id)}
style={styles.detailBtn} style={styles.detailBtn}
> >
{r.ingredient_changes.length} change{r.ingredient_changes.length !== 1 ? 's' : ''} {r.ingredient_changes.length} change{r.ingredient_changes.length !== 1 ? 's' : ''}
{expandedId === r.recipe_id ? ' \u25B4' : ' \u25BE'} {expandedId === r.recipe_id ? ' \u25B4' : ' \u25BE'}
</button> </button>
</td> </td>
</tr> </tr>
{expandedId === r.recipe_id && ( {expandedId === r.recipe_id && (
<tr key={`${r.recipe_id}-detail`}> <tr key={`${r.recipe_id}-detail`}>
<td colSpan={6} style={{ padding: '0 0.75rem 0.75rem 2rem', background: '#fafafa' }}> <td colSpan={6} style={{ padding: '0 0.75rem 0.75rem 2rem', background: '#fafafa' }}>
<div style={{ fontSize: '0.8rem', color: '#555' }}> <div style={{ fontSize: '0.8rem', color: '#555' }}>
{r.ingredient_changes.map((c, i) => ( {r.ingredient_changes.map((c, i) => (
<div key={i} style={{ padding: '3px 0', borderBottom: i < r.ingredient_changes.length - 1 ? '1px solid #eee' : 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div key={i} style={{ padding: '3px 0', borderBottom: i < r.ingredient_changes.length - 1 ? '1px solid #eee' : 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span> <span>
<span style={{ color: '#888', marginRight: '0.5rem' }}>{c.date}</span> <span style={{ color: '#888', marginRight: '0.5rem' }}>{c.date}</span>
{c.source_invoice_id && ( {c.source_invoice_id && (
<span <span
style={{ cursor: 'pointer', color: '#e94560', marginRight: '0.5rem', textDecoration: 'underline' }} style={{ cursor: 'pointer', color: '#e94560', marginRight: '0.5rem', textDecoration: 'underline' }}
onClick={() => navigate(`/invoice/${c.source_invoice_id}`)} onClick={() => navigate(`/invoice/${c.source_invoice_id}`)}
> >
{c.source_invoice_number || `#${c.source_invoice_id}`} {c.source_invoice_number || `#${c.source_invoice_id}`}
</span> </span>
)} )}
{c.ingredient_name && c.old_price != null && c.new_price != null ? ( {c.ingredient_name && c.old_price != null && c.new_price != null ? (
<> <>
{c.ingredient_name} price changed: {formatIngredientPrice(c.old_price, c.unit)} {formatIngredientPrice(c.new_price, c.unit)} {c.ingredient_name} price changed: {formatIngredientPrice(c.old_price, c.unit)} {formatIngredientPrice(c.new_price, c.unit)}
</> </>
) : c.ingredient_name && c.new_price != null && c.old_price == null ? ( ) : c.ingredient_name && c.new_price != null && c.old_price == null ? (
<> <>
{c.ingredient_name} price set: {formatIngredientPrice(c.new_price, c.unit)} {c.ingredient_name} price set: {formatIngredientPrice(c.new_price, c.unit)}
</> </>
) : ( ) : (
c.summary c.summary
)} )}
</span> </span>
{c.cost_impact != null && ( {c.cost_impact != null && (
<span style={{ <span style={{
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: '0.75rem',
color: c.cost_impact > 0 ? '#dc3545' : c.cost_impact < 0 ? '#22c55e' : '#888', color: c.cost_impact > 0 ? '#dc3545' : c.cost_impact < 0 ? '#22c55e' : '#888',
marginLeft: '1rem', marginLeft: '1rem',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}}> }}>
{c.cost_impact > 0 ? '+' : ''}{`\u00A3${c.cost_impact.toFixed(4)}`}/{r.output_unit} {c.cost_impact > 0 ? '+' : ''}{`\u00A3${c.cost_impact.toFixed(4)}`}/{r.output_unit}
</span> </span>
)} )}
</div> </div>
))} ))}
</div> </div>
</td> </td>
</tr> </tr>
)} )}
</> </>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</> </>
)} )}
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' }, page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
select: { padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem' }, select: { padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem' },
statsBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', padding: '0.5rem 0', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#555' }, statsBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', padding: '0.5rem 0', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#555' },
filterBtn: { padding: '3px 10px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' }, filterBtn: { padding: '3px 10px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
filterBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' }, filterBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' },
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }, 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.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' }, th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
tr: { borderBottom: '1px solid #f0f0f0' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' }, td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
detailBtn: { padding: '2px 8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' }, detailBtn: { padding: '2px 8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
} }

View file

@ -1,372 +1,372 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
interface Division { interface Division {
id: number id: number
name: string name: string
} }
interface FlagState { interface FlagState {
food_flag_id: number food_flag_id: number
flag_name: string flag_name: string
flag_code: string | null flag_code: string | null
flag_icon: string | null flag_icon: string | null
category_name: string category_name: string
propagation_type: string propagation_type: string
is_active: boolean is_active: boolean
excludable_on_request: boolean excludable_on_request: boolean
} }
interface Props { interface Props {
menuId?: number menuId?: number
menuName?: string menuName?: string
divisions?: Division[] divisions?: Division[]
preSelectedDivisionId?: number preSelectedDivisionId?: number
recipeId?: number // when opened from DishEditor recipeId?: number // when opened from DishEditor
recipeName?: string recipeName?: string
recipeDesc?: string recipeDesc?: string
recipePrice?: number | null recipePrice?: number | null
onClose: () => void onClose: () => void
onPublished: () => void onPublished: () => void
} }
export default function PublishToMenuModal({ export default function PublishToMenuModal({
menuId: propMenuId, menuId: propMenuId,
divisions: propDivisions, divisions: propDivisions,
preSelectedDivisionId, preSelectedDivisionId,
recipeId: propRecipeId, recipeId: propRecipeId,
recipeName, recipeName,
recipeDesc, recipeDesc,
recipePrice, recipePrice,
onClose, onClose,
onPublished, onPublished,
}: Props) { }: Props) {
const { token, user } = useAuth() const { token, user } = useAuth()
const [step, setStep] = useState<'select' | 'confirm'>('select') const [step, setStep] = useState<'select' | 'confirm'>('select')
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(propRecipeId || null) const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(propRecipeId || null)
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(propMenuId || null) const [selectedMenuId, setSelectedMenuId] = useState<number | null>(propMenuId || null)
const [selectedDivId, setSelectedDivId] = useState<number | null>(preSelectedDivisionId || null) const [selectedDivId, setSelectedDivId] = useState<number | null>(preSelectedDivisionId || null)
const [displayName, setDisplayName] = useState(recipeName || '') const [displayName, setDisplayName] = useState(recipeName || '')
const [description, setDescription] = useState(recipeDesc || '') const [description, setDescription] = useState(recipeDesc || '')
const [price, setPrice] = useState(recipePrice ? String(recipePrice) : '') const [price, setPrice] = useState(recipePrice ? String(recipePrice) : '')
const [confirmedBy, setConfirmedBy] = useState(user?.name || '') const [confirmedBy, setConfirmedBy] = useState(user?.name || '')
const [dishSearch, setDishSearch] = useState('') const [dishSearch, setDishSearch] = useState('')
// LLM FEATURE — see LLM-MANIFEST.md for removal instructions // LLM FEATURE — see LLM-MANIFEST.md for removal instructions
const [aiDescLoading, setAiDescLoading] = useState(false) const [aiDescLoading, setAiDescLoading] = useState(false)
const { data: llmSettings } = useQuery<{ llm_enabled: boolean; anthropic_api_key_set: boolean }>({ const { data: llmSettings } = useQuery<{ llm_enabled: boolean; anthropic_api_key_set: boolean }>({
queryKey: ['settings'], queryKey: ['settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/', { headers: { Authorization: `Bearer ${token}` } }) const res = await fetch('/kitchen/api/settings/', { credentials: 'include' })
if (!res.ok) return { llm_enabled: false, anthropic_api_key_set: false } if (!res.ok) return { llm_enabled: false, anthropic_api_key_set: false }
return res.json() return res.json()
}, },
staleTime: 60000, staleTime: 60000,
}) })
const handleGenerateDescription = async () => { const handleGenerateDescription = async () => {
const recipeIdToUse = selectedRecipeId || propRecipeId const recipeIdToUse = selectedRecipeId || propRecipeId
if (!recipeIdToUse) return if (!recipeIdToUse) return
setAiDescLoading(true) setAiDescLoading(true)
try { try {
const res = await fetch('/kitchen/api/menus/generate-description', { const res = await fetch('/kitchen/api/menus/generate-description', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: recipeIdToUse, recipe_name: displayName || recipeName || '', ingredients: [], allergen_flags: [] }), body: JSON.stringify({ recipe_id: recipeIdToUse, recipe_name: displayName || recipeName || '', ingredients: [], allergen_flags: [] }),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
if (data.description) setDescription(data.description) if (data.description) setDescription(data.description)
} }
} catch { /* ignore */ } } catch { /* ignore */ }
setAiDescLoading(false) setAiDescLoading(false)
} }
// Fetch dishes list (when opened from MenuEditor, need to pick a dish) // Fetch dishes list (when opened from MenuEditor, need to pick a dish)
const { data: dishes } = useQuery<Array<{ id: number; name: string; description: string | null; gross_sell_price: number | null }>>({ const { data: dishes } = useQuery<Array<{ id: number; name: string; description: string | null; gross_sell_price: number | null }>>({
queryKey: ['dishes-for-menu'], queryKey: ['dishes-for-menu'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', { const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch dishes') if (!res.ok) throw new Error('Failed to fetch dishes')
return res.json() return res.json()
}, },
enabled: !!token && !propRecipeId, enabled: !!token && !propRecipeId,
}) })
// Fetch menus list (when opened from DishEditor, need to pick a menu) // Fetch menus list (when opened from DishEditor, need to pick a menu)
const { data: menus } = useQuery<Array<{ id: number; name: string; is_active: boolean }>>({ const { data: menus } = useQuery<Array<{ id: number; name: string; is_active: boolean }>>({
queryKey: ['menus-for-publish'], queryKey: ['menus-for-publish'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/menus', { const res = await fetch('/kitchen/api/menus', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch menus') if (!res.ok) throw new Error('Failed to fetch menus')
return res.json() return res.json()
}, },
enabled: !!token && !propMenuId, enabled: !!token && !propMenuId,
}) })
// Fetch divisions for selected menu (when from DishEditor) // Fetch divisions for selected menu (when from DishEditor)
const { data: menuDetail } = useQuery<{ divisions: Division[] }>({ const { data: menuDetail } = useQuery<{ divisions: Division[] }>({
queryKey: ['menu-divisions', selectedMenuId], queryKey: ['menu-divisions', selectedMenuId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}`, { const res = await fetch(`/kitchen/api/menus/${selectedMenuId}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
return res.json() return res.json()
}, },
enabled: !!token && !!selectedMenuId && !propMenuId, enabled: !!token && !!selectedMenuId && !propMenuId,
}) })
// Fetch recipe flags when a recipe is selected // Fetch recipe flags when a recipe is selected
const { data: flagData, isLoading: flagsLoading } = useQuery<{ const { data: flagData, isLoading: flagsLoading } = useQuery<{
flags: FlagState[] flags: FlagState[]
unassessed_ingredients: Array<{ id: number; name: string; category: string }> unassessed_ingredients: Array<{ id: number; name: string; category: string }>
}>({ }>({
queryKey: ['recipe-flags-for-publish', selectedRecipeId], queryKey: ['recipe-flags-for-publish', selectedRecipeId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${selectedRecipeId}/flags`, { const res = await fetch(`/kitchen/api/food-flags/recipes/${selectedRecipeId}/flags`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed') if (!res.ok) throw new Error('Failed')
return res.json() return res.json()
}, },
enabled: !!token && !!selectedRecipeId, enabled: !!token && !!selectedRecipeId,
}) })
const divisions = propDivisions || menuDetail?.divisions || [] const divisions = propDivisions || menuDetail?.divisions || []
const activeMenus = (menus || []).filter(m => m.is_active) const activeMenus = (menus || []).filter(m => m.is_active)
const unassessed = flagData?.unassessed_ingredients || [] const unassessed = flagData?.unassessed_ingredients || []
const activeFlags = (flagData?.flags || []).filter(f => f.is_active) const activeFlags = (flagData?.flags || []).filter(f => f.is_active)
const hasUnassessed = unassessed.length > 0 const hasUnassessed = unassessed.length > 0
// When a dish is selected (from dish picker), populate fields // When a dish is selected (from dish picker), populate fields
useEffect(() => { useEffect(() => {
if (selectedRecipeId && dishes) { if (selectedRecipeId && dishes) {
const dish = dishes.find(d => d.id === selectedRecipeId) const dish = dishes.find(d => d.id === selectedRecipeId)
if (dish) { if (dish) {
setDisplayName(dish.name) setDisplayName(dish.name)
setDescription(dish.description || '') setDescription(dish.description || '')
setPrice(dish.gross_sell_price ? String(dish.gross_sell_price) : '') setPrice(dish.gross_sell_price ? String(dish.gross_sell_price) : '')
} }
} }
}, [selectedRecipeId, dishes]) }, [selectedRecipeId, dishes])
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}/items`, { const res = await fetch(`/kitchen/api/menus/${selectedMenuId}/items`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
recipe_id: selectedRecipeId, recipe_id: selectedRecipeId,
division_id: selectedDivId, division_id: selectedDivId,
display_name: displayName.trim(), display_name: displayName.trim(),
description: description.trim() || null, description: description.trim() || null,
price: price ? parseFloat(price) : null, price: price ? parseFloat(price) : null,
confirmed_by_name: confirmedBy.trim(), confirmed_by_name: confirmedBy.trim(),
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}))
throw new Error(typeof err.detail === 'string' ? err.detail : err.detail?.message || 'Failed to publish') throw new Error(typeof err.detail === 'string' ? err.detail : err.detail?.message || 'Failed to publish')
} }
return res.json() return res.json()
}, },
onSuccess: () => onPublished(), onSuccess: () => onPublished(),
}) })
const filteredDishes = (dishes || []).filter(d => const filteredDishes = (dishes || []).filter(d =>
!dishSearch || d.name.toLowerCase().includes(dishSearch.toLowerCase()) !dishSearch || d.name.toLowerCase().includes(dishSearch.toLowerCase())
) )
return ( return (
<div style={styles.overlay} onClick={onClose}> <div style={styles.overlay} onClick={onClose}>
<div style={{ ...styles.modal, maxWidth: '550px' }} onClick={(e) => e.stopPropagation()}> <div style={{ ...styles.modal, maxWidth: '550px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1rem' }}> <h3 style={{ margin: '0 0 1rem' }}>
{step === 'select' ? 'Publish Dish to Menu' : 'Confirm Allergens & Publish'} {step === 'select' ? 'Publish Dish to Menu' : 'Confirm Allergens & Publish'}
</h3> </h3>
{step === 'select' && ( {step === 'select' && (
<> <>
{/* Dish selection (when from MenuEditor) */} {/* Dish selection (when from MenuEditor) */}
{!propRecipeId && ( {!propRecipeId && (
<> <>
<label style={styles.label}>Select Dish *</label> <label style={styles.label}>Select Dish *</label>
<input <input
type="text" type="text"
value={dishSearch} value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)} onChange={(e) => setDishSearch(e.target.value)}
style={styles.input} style={styles.input}
placeholder="Search dishes..." placeholder="Search dishes..."
/> />
<div style={{ maxHeight: '200px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.25rem' }}> <div style={{ maxHeight: '200px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.25rem' }}>
{filteredDishes.map(dish => ( {filteredDishes.map(dish => (
<div <div
key={dish.id} key={dish.id}
onClick={() => setSelectedRecipeId(dish.id)} onClick={() => setSelectedRecipeId(dish.id)}
style={{ style={{
padding: '0.5rem', padding: '0.5rem',
cursor: 'pointer', cursor: 'pointer',
background: selectedRecipeId === dish.id ? '#eff6ff' : '#fff', background: selectedRecipeId === dish.id ? '#eff6ff' : '#fff',
borderBottom: '1px solid #f3f4f6', borderBottom: '1px solid #f3f4f6',
}} }}
> >
<span style={{ fontWeight: selectedRecipeId === dish.id ? 600 : 400 }}>{dish.name}</span> <span style={{ fontWeight: selectedRecipeId === dish.id ? 600 : 400 }}>{dish.name}</span>
</div> </div>
))} ))}
{filteredDishes.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>} {filteredDishes.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>}
</div> </div>
</> </>
)} )}
{/* Menu selection (when from DishEditor) */} {/* Menu selection (when from DishEditor) */}
{!propMenuId && ( {!propMenuId && (
<> <>
<label style={styles.label}>Menu *</label> <label style={styles.label}>Menu *</label>
<select <select
value={selectedMenuId || ''} value={selectedMenuId || ''}
onChange={(e) => { setSelectedMenuId(parseInt(e.target.value)); setSelectedDivId(null) }} onChange={(e) => { setSelectedMenuId(parseInt(e.target.value)); setSelectedDivId(null) }}
style={styles.input} style={styles.input}
> >
<option value="">Select a menu...</option> <option value="">Select a menu...</option>
{activeMenus.map(m => <option key={m.id} value={m.id}>{m.name}</option>)} {activeMenus.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select> </select>
</> </>
)} )}
{/* Division selection */} {/* Division selection */}
{divisions.length > 0 && ( {divisions.length > 0 && (
<> <>
<label style={styles.label}>Section *</label> <label style={styles.label}>Section *</label>
<select <select
value={selectedDivId || ''} value={selectedDivId || ''}
onChange={(e) => setSelectedDivId(parseInt(e.target.value))} onChange={(e) => setSelectedDivId(parseInt(e.target.value))}
style={styles.input} style={styles.input}
> >
<option value="">Select section...</option> <option value="">Select section...</option>
{divisions.map(d => <option key={d.id} value={d.id}>{d.name}</option>)} {divisions.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select> </select>
</> </>
)} )}
<div style={styles.modalActions}> <div style={styles.modalActions}>
<button onClick={onClose} style={styles.btn}>Cancel</button> <button onClick={onClose} style={styles.btn}>Cancel</button>
<button <button
onClick={() => setStep('confirm')} onClick={() => setStep('confirm')}
style={styles.btnPrimary} style={styles.btnPrimary}
disabled={!selectedRecipeId || !selectedMenuId || !selectedDivId} disabled={!selectedRecipeId || !selectedMenuId || !selectedDivId}
> >
Next: Review Allergens Next: Review Allergens
</button> </button>
</div> </div>
</> </>
)} )}
{step === 'confirm' && ( {step === 'confirm' && (
<> <>
{flagsLoading && <p>Loading allergen data...</p>} {flagsLoading && <p>Loading allergen data...</p>}
{!flagsLoading && hasUnassessed && ( {!flagsLoading && hasUnassessed && (
<div style={{ background: '#fee2e2', padding: '0.75rem', borderRadius: '6px', marginBottom: '1rem' }}> <div style={{ background: '#fee2e2', padding: '0.75rem', borderRadius: '6px', marginBottom: '1rem' }}>
<strong style={{ color: '#dc2626' }}>Cannot publish: unassessed ingredients</strong> <strong style={{ color: '#dc2626' }}>Cannot publish: unassessed ingredients</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}> <ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
{unassessed.map((u, i) => <li key={i} style={{ fontSize: '0.875rem' }}>{u.name} {u.category}</li>)} {unassessed.map((u, i) => <li key={i} style={{ fontSize: '0.875rem' }}>{u.name} {u.category}</li>)}
</ul> </ul>
</div> </div>
)} )}
{!flagsLoading && !hasUnassessed && ( {!flagsLoading && !hasUnassessed && (
<> <>
{activeFlags.length > 0 && ( {activeFlags.length > 0 && (
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<label style={styles.label}>Confirmed Allergens</label> <label style={styles.label}>Confirmed Allergens</label>
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{activeFlags.map(f => ( {activeFlags.map(f => (
<span key={f.food_flag_id} style={{ <span key={f.food_flag_id} style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem', padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem',
background: f.propagation_type === 'contains' ? '#fee2e2' : '#dcfce7', background: f.propagation_type === 'contains' ? '#fee2e2' : '#dcfce7',
color: f.propagation_type === 'contains' ? '#991b1b' : '#166534', color: f.propagation_type === 'contains' ? '#991b1b' : '#166534',
}}> }}>
{f.flag_code || f.flag_name} {f.flag_code || f.flag_name}
{f.excludable_on_request && ' *'} {f.excludable_on_request && ' *'}
</span> </span>
))} ))}
</div> </div>
</div> </div>
)} )}
<label style={styles.label}>Display Name *</label> <label style={styles.label}>Display Name *</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={styles.input} /> <input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={styles.input} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={styles.label}>Description</label> <label style={styles.label}>Description</label>
{/* LLM FEATURE — see LLM-MANIFEST.md for removal instructions */} {/* LLM FEATURE — see LLM-MANIFEST.md for removal instructions */}
{llmSettings?.llm_enabled && llmSettings?.anthropic_api_key_set && ( {llmSettings?.llm_enabled && llmSettings?.anthropic_api_key_set && (
<button <button
onClick={handleGenerateDescription} onClick={handleGenerateDescription}
disabled={aiDescLoading} disabled={aiDescLoading}
style={{ padding: '0.2rem 0.6rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiDescLoading ? 'wait' : 'pointer', fontSize: '0.75rem', opacity: aiDescLoading ? 0.7 : 1 }} style={{ padding: '0.2rem 0.6rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiDescLoading ? 'wait' : 'pointer', fontSize: '0.75rem', opacity: aiDescLoading ? 0.7 : 1 }}
> >
{aiDescLoading ? 'Generating...' : '\u2728 Generate'} {aiDescLoading ? 'Generating...' : '\u2728 Generate'}
</button> </button>
)} )}
</div> </div>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }} style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }}
/> />
<label style={styles.label}>Price</label> <label style={styles.label}>Price</label>
<input <input
type="number" type="number"
step="0.01" step="0.01"
value={price} value={price}
onChange={(e) => setPrice(e.target.value)} onChange={(e) => setPrice(e.target.value)}
style={{ ...styles.input, width: '120px' }} style={{ ...styles.input, width: '120px' }}
/> />
<label style={styles.label}>Confirmed by *</label> <label style={styles.label}>Confirmed by *</label>
<input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} /> <input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} />
</> </>
)} )}
<div style={styles.modalActions}> <div style={styles.modalActions}>
<button onClick={() => setStep('select')} style={styles.btn}>Back</button> <button onClick={() => setStep('select')} style={styles.btn}>Back</button>
<button onClick={onClose} style={styles.btn}>Cancel</button> <button onClick={onClose} style={styles.btn}>Cancel</button>
{!hasUnassessed && ( {!hasUnassessed && (
<button <button
onClick={() => publishMutation.mutate()} onClick={() => publishMutation.mutate()}
style={styles.btnPrimary} style={styles.btnPrimary}
disabled={!displayName.trim() || !confirmedBy.trim() || publishMutation.isPending} disabled={!displayName.trim() || !confirmedBy.trim() || publishMutation.isPending}
> >
{publishMutation.isPending ? 'Publishing...' : 'Publish to Menu'} {publishMutation.isPending ? 'Publishing...' : 'Publish to Menu'}
</button> </button>
)} )}
</div> </div>
{publishMutation.isError && ( {publishMutation.isError && (
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}> <p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
{(publishMutation.error as Error).message} {(publishMutation.error as Error).message}
</p> </p>
)} )}
</> </>
)} )}
</div> </div>
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' }, modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' }, modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' }, label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' }, input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' }, btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' }, btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
} }

View file

@ -1,283 +1,283 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
import PurchaseOrderModal from './PurchaseOrderModal' import PurchaseOrderModal from './PurchaseOrderModal'
interface PurchaseOrderSummary { interface PurchaseOrderSummary {
id: number id: number
supplier_id: number supplier_id: number
supplier_name: string | null supplier_name: string | null
order_date: string order_date: string
order_type: string order_type: string
status: string status: string
total_amount: number | null total_amount: number | null
order_reference: string | null order_reference: string | null
created_by_name: string | null created_by_name: string | null
created_at: string created_at: string
} }
const statusColors: Record<string, { bg: string; color: string }> = { const statusColors: Record<string, { bg: string; color: string }> = {
DRAFT: { bg: '#e0e0e0', color: '#555' }, DRAFT: { bg: '#e0e0e0', color: '#555' },
PENDING: { bg: '#e3f2fd', color: '#1565c0' }, PENDING: { bg: '#e3f2fd', color: '#1565c0' },
LINKED: { bg: '#d4edda', color: '#155724' }, LINKED: { bg: '#d4edda', color: '#155724' },
CLOSED: { bg: '#f5f5f5', color: '#666' }, CLOSED: { bg: '#f5f5f5', color: '#666' },
CANCELLED: { bg: '#ffebee', color: '#c62828' }, CANCELLED: { bg: '#ffebee', color: '#c62828' },
} }
export default function PurchaseOrderList() { export default function PurchaseOrderList() {
const { token } = useAuth() const { token } = useAuth()
const [statusFilter, setStatusFilter] = useState('DRAFT,PENDING') const [statusFilter, setStatusFilter] = useState('DRAFT,PENDING')
const [supplierFilter, setSupplierFilter] = useState('') const [supplierFilter, setSupplierFilter] = useState('')
const [editPoId, setEditPoId] = useState<number | null>(null) const [editPoId, setEditPoId] = useState<number | null>(null)
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
// Fetch suppliers for filter // Fetch suppliers for filter
const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({ const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Build query string // Build query string
const params = new URLSearchParams() const params = new URLSearchParams()
if (statusFilter) params.append('status', statusFilter) if (statusFilter) params.append('status', statusFilter)
if (supplierFilter) params.append('supplier_id', supplierFilter) if (supplierFilter) params.append('supplier_id', supplierFilter)
const { data: poList, refetch } = useQuery<PurchaseOrderSummary[]>({ const { data: poList, refetch } = useQuery<PurchaseOrderSummary[]>({
queryKey: ['purchase-orders', statusFilter, supplierFilter], queryKey: ['purchase-orders', statusFilter, supplierFilter],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/purchase-orders/?${params}`, { const res = await fetch(`/kitchen/api/purchase-orders/?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch purchase orders') if (!res.ok) throw new Error('Failed to fetch purchase orders')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const formatDate = (d: string) => { const formatDate = (d: string) => {
const dt = new Date(d + 'T00:00:00') const dt = new Date(d + 'T00:00:00')
return dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) return dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
} }
return ( return (
<div> <div>
<div style={styles.headerRow}> <div style={styles.headerRow}>
<h2 style={styles.pageTitle}>Purchase Orders</h2> <h2 style={styles.pageTitle}>Purchase Orders</h2>
<button style={styles.newBtn} onClick={() => setShowNewModal(true)}>+ New PO</button> <button style={styles.newBtn} onClick={() => setShowNewModal(true)}>+ New PO</button>
</div> </div>
{/* Filter bar */} {/* Filter bar */}
<div style={styles.filterBar}> <div style={styles.filterBar}>
<div style={styles.statusTabs}> <div style={styles.statusTabs}>
{[ {[
{ label: 'Open', value: 'DRAFT,PENDING' }, { label: 'Open', value: 'DRAFT,PENDING' },
{ label: 'All', value: '' }, { label: 'All', value: '' },
{ label: 'Draft', value: 'DRAFT' }, { label: 'Draft', value: 'DRAFT' },
{ label: 'Pending', value: 'PENDING' }, { label: 'Pending', value: 'PENDING' },
{ label: 'Linked', value: 'LINKED' }, { label: 'Linked', value: 'LINKED' },
{ label: 'Closed', value: 'CLOSED' }, { label: 'Closed', value: 'CLOSED' },
].map(tab => ( ].map(tab => (
<button <button
key={tab.value} key={tab.value}
style={{ style={{
...styles.filterTab, ...styles.filterTab,
...(statusFilter === tab.value ? styles.filterTabActive : {}), ...(statusFilter === tab.value ? styles.filterTabActive : {}),
}} }}
onClick={() => setStatusFilter(tab.value)} onClick={() => setStatusFilter(tab.value)}
> >
{tab.label} {tab.label}
</button> </button>
))} ))}
</div> </div>
<select <select
value={supplierFilter} value={supplierFilter}
onChange={e => setSupplierFilter(e.target.value)} onChange={e => setSupplierFilter(e.target.value)}
style={styles.filterSelect} style={styles.filterSelect}
> >
<option value="">All Suppliers</option> <option value="">All Suppliers</option>
{suppliersData?.suppliers?.map(s => ( {suppliersData?.suppliers?.map(s => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
</div> </div>
{/* Table */} {/* Table */}
<div style={styles.tableContainer}> <div style={styles.tableContainer}>
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Date</th> <th style={styles.th}>Date</th>
<th style={styles.th}>Supplier</th> <th style={styles.th}>Supplier</th>
<th style={styles.th}>Type</th> <th style={styles.th}>Type</th>
<th style={styles.th}>Reference</th> <th style={styles.th}>Reference</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Total</th> <th style={{ ...styles.th, textAlign: 'right' }}>Total</th>
<th style={styles.th}>Status</th> <th style={styles.th}>Status</th>
<th style={styles.th}>Created</th> <th style={styles.th}>Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(!poList || poList.length === 0) ? ( {(!poList || poList.length === 0) ? (
<tr> <tr>
<td colSpan={7} style={styles.emptyTd}>No purchase orders found</td> <td colSpan={7} style={styles.emptyTd}>No purchase orders found</td>
</tr> </tr>
) : ( ) : (
poList.map(po => { poList.map(po => {
const sc = statusColors[po.status] || statusColors.DRAFT const sc = statusColors[po.status] || statusColors.DRAFT
return ( return (
<tr <tr
key={po.id} key={po.id}
style={styles.row} style={styles.row}
onClick={() => setEditPoId(po.id)} onClick={() => setEditPoId(po.id)}
> >
<td style={styles.td}>{formatDate(po.order_date)}</td> <td style={styles.td}>{formatDate(po.order_date)}</td>
<td style={styles.td}>{po.supplier_name || '-'}</td> <td style={styles.td}>{po.supplier_name || '-'}</td>
<td style={styles.td}> <td style={styles.td}>
<span style={styles.typeBadge}> <span style={styles.typeBadge}>
{po.order_type === 'itemised' ? 'Itemised' : 'Single Value'} {po.order_type === 'itemised' ? 'Itemised' : 'Single Value'}
</span> </span>
</td> </td>
<td style={styles.td}>{po.order_reference || '-'}</td> <td style={styles.td}>{po.order_reference || '-'}</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 500 }}> <td style={{ ...styles.td, textAlign: 'right', fontWeight: 500 }}>
{po.total_amount != null ? `\u00A3${po.total_amount.toFixed(2)}` : '-'} {po.total_amount != null ? `\u00A3${po.total_amount.toFixed(2)}` : '-'}
</td> </td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ <span style={{
...styles.statusBadge, ...styles.statusBadge,
background: sc.bg, background: sc.bg,
color: sc.color, color: sc.color,
}}> }}>
{po.status} {po.status}
</span> </span>
</td> </td>
<td style={{ ...styles.td, fontSize: '0.8rem', color: '#888' }}> <td style={{ ...styles.td, fontSize: '0.8rem', color: '#888' }}>
{po.created_by_name || ''} {po.created_by_name || ''}
</td> </td>
</tr> </tr>
) )
}) })
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Modals */} {/* Modals */}
<PurchaseOrderModal <PurchaseOrderModal
isOpen={!!editPoId} isOpen={!!editPoId}
onClose={() => setEditPoId(null)} onClose={() => setEditPoId(null)}
onSaved={() => refetch()} onSaved={() => refetch()}
poId={editPoId} poId={editPoId}
/> />
<PurchaseOrderModal <PurchaseOrderModal
isOpen={showNewModal} isOpen={showNewModal}
onClose={() => setShowNewModal(false)} onClose={() => setShowNewModal(false)}
onSaved={() => refetch()} onSaved={() => refetch()}
/> />
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
headerRow: { headerRow: {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: '1.5rem', marginBottom: '1.5rem',
}, },
pageTitle: { pageTitle: {
margin: 0, margin: 0,
color: '#1a1a2e', color: '#1a1a2e',
}, },
newBtn: { newBtn: {
padding: '0.6rem 1.2rem', padding: '0.6rem 1.2rem',
background: '#1a1a2e', background: '#1a1a2e',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: 'pointer', cursor: 'pointer',
fontWeight: 600, fontWeight: 600,
fontSize: '0.9rem', fontSize: '0.9rem',
}, },
filterBar: { filterBar: {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: '1rem', marginBottom: '1rem',
gap: '1rem', gap: '1rem',
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
statusTabs: { statusTabs: {
display: 'flex', display: 'flex',
gap: '0.25rem', gap: '0.25rem',
}, },
filterTab: { filterTab: {
padding: '0.4rem 0.75rem', padding: '0.4rem 0.75rem',
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '6px', borderRadius: '6px',
background: 'white', background: 'white',
cursor: 'pointer', cursor: 'pointer',
fontSize: '0.85rem', fontSize: '0.85rem',
fontWeight: 500, fontWeight: 500,
}, },
filterTabActive: { filterTabActive: {
background: '#1a1a2e', background: '#1a1a2e',
color: 'white', color: 'white',
borderColor: '#1a1a2e', borderColor: '#1a1a2e',
}, },
filterSelect: { filterSelect: {
padding: '0.4rem 0.75rem', padding: '0.4rem 0.75rem',
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '6px', borderRadius: '6px',
fontSize: '0.85rem', fontSize: '0.85rem',
}, },
tableContainer: { tableContainer: {
background: 'white', background: 'white',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)', boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
overflow: 'auto', overflow: 'auto',
}, },
table: { table: {
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
}, },
th: { th: {
textAlign: 'left', textAlign: 'left',
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
borderBottom: '2px solid #eee', borderBottom: '2px solid #eee',
fontWeight: 600, fontWeight: 600,
color: '#666', color: '#666',
fontSize: '0.85rem', fontSize: '0.85rem',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}, },
td: { td: {
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
fontSize: '0.9rem', fontSize: '0.9rem',
}, },
emptyTd: { emptyTd: {
padding: '2rem', padding: '2rem',
textAlign: 'center', textAlign: 'center',
color: '#999', color: '#999',
}, },
row: { row: {
cursor: 'pointer', cursor: 'pointer',
transition: 'background 0.15s', transition: 'background 0.15s',
}, },
statusBadge: { statusBadge: {
display: 'inline-block', display: 'inline-block',
padding: '0.2rem 0.6rem', padding: '0.2rem 0.6rem',
borderRadius: '12px', borderRadius: '12px',
fontSize: '0.75rem', fontSize: '0.75rem',
fontWeight: 600, fontWeight: 600,
}, },
typeBadge: { typeBadge: {
fontSize: '0.8rem', fontSize: '0.8rem',
color: '#666', color: '#666',
}, },
} }

File diff suppressed because it is too large Load diff

View file

@ -282,7 +282,7 @@ export default function Purchases() {
queryKey: ['purchases-range', submittedFromDate, submittedToDate], queryKey: ['purchases-range', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/purchases/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/purchases/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch purchases') if (!res.ok) throw new Error('Failed to fetch purchases')
return res.json() return res.json()
@ -294,7 +294,7 @@ export default function Purchases() {
queryKey: ['daily-dispute-stats', submittedFromDate, submittedToDate], queryKey: ['daily-dispute-stats', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/disputes/stats/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/disputes/stats/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch dispute stats') if (!res.ok) throw new Error('Failed to fetch dispute stats')
return res.json() return res.json()
@ -306,7 +306,7 @@ export default function Purchases() {
queryKey: ['daily-allowance-stats', submittedFromDate, submittedToDate], queryKey: ['daily-allowance-stats', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/logbook/daily-stats?date_from=${submittedFromDate}&date_to=${submittedToDate}`, { const res = await fetch(`/kitchen/api/logbook/daily-stats?date_from=${submittedFromDate}&date_to=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch allowance stats') if (!res.ok) throw new Error('Failed to fetch allowance stats')
return res.json() return res.json()
@ -380,7 +380,7 @@ export default function Purchases() {
queryKey: ['weekly-chart-data', weeklyChartDateRange.from, weeklyChartDateRange.to], queryKey: ['weekly-chart-data', weeklyChartDateRange.from, weeklyChartDateRange.to],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${weeklyChartDateRange.from}&to_date=${weeklyChartDateRange.to}`, { const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${weeklyChartDateRange.from}&to_date=${weeklyChartDateRange.to}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch weekly chart data') if (!res.ok) throw new Error('Failed to fetch weekly chart data')
return res.json() return res.json()

View file

@ -271,7 +271,7 @@ export default function PurchasesReport() {
queryKey: ['purchases-summary', submittedFromDate, submittedToDate], queryKey: ['purchases-summary', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/purchases/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/purchases/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch purchases summary') if (!res.ok) throw new Error('Failed to fetch purchases summary')
return res.json() return res.json()
@ -285,7 +285,7 @@ export default function PurchasesReport() {
queryKey: ['purchases-daily-supplier', submittedFromDate, submittedToDate], queryKey: ['purchases-daily-supplier', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/purchases/daily-by-supplier?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/reports/purchases/daily-by-supplier?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch chart data') if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json() return res.json()
@ -303,7 +303,7 @@ export default function PurchasesReport() {
url += `&supplier_id=${topItemsSupplierFilter}` url += `&supplier_id=${topItemsSupplierFilter}`
} }
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch top items') if (!res.ok) throw new Error('Failed to fetch top items')
return res.json() return res.json()

File diff suppressed because it is too large Load diff

View file

@ -1,333 +1,333 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App' import { useAuth } from '../App'
interface MatrixCell { interface MatrixCell {
has_flag: boolean has_flag: boolean
is_unassessed: boolean is_unassessed: boolean
is_none: boolean is_none: boolean
has_open_suggestion?: boolean has_open_suggestion?: boolean
} }
interface MatrixIngredient { interface MatrixIngredient {
ingredient_id: number ingredient_id: number
ingredient_name: string ingredient_name: string
is_sub_recipe: boolean is_sub_recipe: boolean
sub_recipe_name: string | null sub_recipe_name: string | null
flags: Record<number, MatrixCell> flags: Record<number, MatrixCell>
} }
interface FlagColumn { interface FlagColumn {
id: number id: number
name: string name: string
code: string | null code: string | null
category_id: number category_id: number
category_name: string category_name: string
propagation_type: string propagation_type: string
required: boolean required: boolean
} }
interface MatrixData { interface MatrixData {
flags: FlagColumn[] flags: FlagColumn[]
ingredients: MatrixIngredient[] ingredients: MatrixIngredient[]
} }
interface Props { interface Props {
recipeId: number recipeId: number
categoryId?: number categoryId?: number
} }
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) { export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
const { token } = useAuth() const { token } = useAuth()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set()) const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
const { data: rawData, isLoading } = useQuery<MatrixData>({ const { data: rawData, isLoading } = useQuery<MatrixData>({
queryKey: ['recipe-flag-matrix', recipeId], queryKey: ['recipe-flag-matrix', recipeId],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, { const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch matrix') if (!res.ok) throw new Error('Failed to fetch matrix')
return res.json() return res.json()
}, },
enabled: !!token && !!recipeId, enabled: !!token && !!recipeId,
}) })
// Filter to specific category if provided // Filter to specific category if provided
const data = rawData ? { const data = rawData ? {
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags, flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
ingredients: rawData.ingredients, ingredients: rawData.ingredients,
} : undefined } : undefined
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => { mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, { const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
method: 'PUT', method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }), body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
}) })
if (!res.ok) throw new Error('Failed to update flag') if (!res.ok) throw new Error('Failed to update flag')
}, },
onMutate: ({ ingredientId, flagId }) => { onMutate: ({ ingredientId, flagId }) => {
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`)) setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
}, },
onSettled: (_data, _err, { ingredientId, flagId }) => { onSettled: (_data, _err, { ingredientId, flagId }) => {
setPendingCells(prev => { setPendingCells(prev => {
const next = new Set(prev) const next = new Set(prev)
next.delete(`${ingredientId}-${flagId}`) next.delete(`${ingredientId}-${flagId}`)
return next return next
}) })
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] }) queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] }) queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] }) queryClient.invalidateQueries({ queryKey: ['ingredients'] })
}, },
}) })
const toggleNoneMutation = useMutation({ const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => { mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, { const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }), body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
}) })
if (!res.ok) throw new Error('Failed to toggle none') if (!res.ok) throw new Error('Failed to toggle none')
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] }) queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] }) queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] }) queryClient.invalidateQueries({ queryKey: ['ingredients'] })
}, },
}) })
if (isLoading) return <div style={{ padding: '1rem', color: '#888' }}>Loading matrix...</div> if (isLoading) return <div style={{ padding: '1rem', color: '#888' }}>Loading matrix...</div>
if (!data || !data.flags.length) return <div style={{ padding: '1rem', color: '#888' }}>No flags configured for this category</div> if (!data || !data.flags.length) return <div style={{ padding: '1rem', color: '#888' }}>No flags configured for this category</div>
// Group flags by category // Group flags by category
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {} const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
for (const f of data.flags) { for (const f of data.flags) {
if (!categories[f.category_id]) { if (!categories[f.category_id]) {
categories[f.category_id] = { name: f.category_name, propagation: f.propagation_type, required: f.required, flags: [] } categories[f.category_id] = { name: f.category_name, propagation: f.propagation_type, required: f.required, flags: [] }
} }
categories[f.category_id].flags.push(f) categories[f.category_id].flags.push(f)
} }
// Group ingredients by sub-recipe // Group ingredients by sub-recipe
let currentSubRecipe = '' let currentSubRecipe = ''
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = [] const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
for (const ing of data.ingredients) { for (const ing of data.ingredients) {
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) { if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
currentSubRecipe = ing.sub_recipe_name || '' currentSubRecipe = ing.sub_recipe_name || ''
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` }) rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
} else if (!ing.is_sub_recipe && currentSubRecipe) { } else if (!ing.is_sub_recipe && currentSubRecipe) {
currentSubRecipe = '' currentSubRecipe = ''
} }
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing }) rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
} }
// Compute totals per flag // Compute totals per flag
const totals: Record<number, { has: boolean; unassessed: boolean }> = {} const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
for (const f of data.flags) { for (const f of data.flags) {
const cat = categories[f.category_id] const cat = categories[f.category_id]
if (cat.propagation === 'contains') { if (cat.propagation === 'contains') {
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag) const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
totals[f.id] = { has: anyHas, unassessed: false } totals[f.id] = { has: anyHas, unassessed: false }
} else { } else {
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag) const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed) const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed } totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
} }
} }
// Check if "none" is set per ingredient per category // Check if "none" is set per ingredient per category
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => { const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
const catFlags = categories[catId]?.flags || [] const catFlags = categories[catId]?.flags || []
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none) return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
} }
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => { const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag }) toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
} }
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => { const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
const isPending = pendingCells.has(`${ingredientId}-${flagId}`) const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
const hasFlag = cell?.has_flag ?? false const hasFlag = cell?.has_flag ?? false
const isUnassessed = cell?.is_unassessed ?? false const isUnassessed = cell?.is_unassessed ?? false
const isNone = cell?.is_none ?? false const isNone = cell?.is_none ?? false
const hasOpenSuggestion = cell?.has_open_suggestion ?? false const hasOpenSuggestion = cell?.has_open_suggestion ?? false
const cellStyle: React.CSSProperties = { const cellStyle: React.CSSProperties = {
...styles.cell, ...styles.cell,
cursor: 'pointer', cursor: 'pointer',
opacity: isPending ? 0.5 : 1, opacity: isPending ? 0.5 : 1,
transition: 'background 0.15s', transition: 'background 0.15s',
position: 'relative' as const, position: 'relative' as const,
} }
// Small amber dot for open suggestions (not already flagged or unassessed) // Small amber dot for open suggestions (not already flagged or unassessed)
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? ( const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
<span style={{ <span style={{
position: 'absolute', top: '2px', right: '2px', position: 'absolute', top: '2px', right: '2px',
width: '6px', height: '6px', borderRadius: '50%', width: '6px', height: '6px', borderRadius: '50%',
background: '#f59e0b', background: '#f59e0b',
}} title="Unreviewed allergen suggestion" /> }} title="Unreviewed allergen suggestion" />
) : null ) : null
const handleClick = () => { const handleClick = () => {
if (isPending) return if (isPending) return
if (isUnassessed) { if (isUnassessed) {
handleToggle(ingredientId, flagId, false) handleToggle(ingredientId, flagId, false)
} else { } else {
handleToggle(ingredientId, flagId, hasFlag) handleToggle(ingredientId, flagId, hasFlag)
} }
} }
if (isUnassessed) { if (isUnassessed) {
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td> return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
} }
if (isNone) { if (isNone) {
return ( return (
<td <td
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }} style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
title={`None apply \u2014 click to set ${flagName || 'this flag'}`} title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
onClick={handleClick} onClick={handleClick}
> >
{'\u2014'}{suggestionDot} {'\u2014'}{suggestionDot}
</td> </td>
) )
} }
if (propagation === 'contains') { if (propagation === 'contains') {
return ( return (
<td <td
style={{ ...cellStyle, color: hasFlag ? '#dc3545' : '#ccc', background: hasFlag ? '#fef2f2' : hasOpenSuggestion ? '#fffbeb' : undefined }} style={{ ...cellStyle, color: hasFlag ? '#dc3545' : '#ccc', background: hasFlag ? '#fef2f2' : hasOpenSuggestion ? '#fffbeb' : undefined }}
title={hasFlag ? `Contains ${flagName || ''} \u2014 click to remove` : hasOpenSuggestion ? `Unreviewed suggestion: ${flagName || ''} \u2014 click to set` : `Click to mark as contains ${flagName || ''}`} title={hasFlag ? `Contains ${flagName || ''} \u2014 click to remove` : hasOpenSuggestion ? `Unreviewed suggestion: ${flagName || ''} \u2014 click to set` : `Click to mark as contains ${flagName || ''}`}
onClick={handleClick} onClick={handleClick}
> >
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot} {hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
</td> </td>
) )
} else { } else {
return ( return (
<td <td
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }} style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`} title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
onClick={handleClick} onClick={handleClick}
> >
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot} {hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
</td> </td>
) )
} }
} }
const showNoneColumn = Object.values(categories).some(c => c.required) const showNoneColumn = Object.values(categories).some(c => c.required)
return ( return (
<div style={styles.container}> <div style={styles.container}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#888' }}> <span style={{ fontSize: '0.75rem', color: '#888' }}>
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'} Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
</span> </span>
</div> </div>
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}> <div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
<table style={styles.table}> <table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}> <thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
<tr> <tr>
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th> <th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
{Object.entries(categories).map(([catId, cat]) => ( {Object.entries(categories).map(([catId, cat]) => (
<> <>
{cat.flags.map(f => ( {cat.flags.map(f => (
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}> <th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
{Object.keys(categories).length > 1 && ( {Object.keys(categories).length > 1 && (
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div> <div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
)} )}
<div>{f.code || f.name.substring(0, 4)}</div> <div>{f.code || f.name.substring(0, 4)}</div>
</th> </th>
))} ))}
{cat.required && ( {cat.required && (
<th key={`none-${catId}`} style={{ ...styles.th, borderLeft: '2px solid #e0e0e0', fontSize: '0.6rem', minWidth: '28px' }} title={`None apply for ${cat.name}`}> <th key={`none-${catId}`} style={{ ...styles.th, borderLeft: '2px solid #e0e0e0', fontSize: '0.6rem', minWidth: '28px' }} title={`None apply for ${cat.name}`}>
<div>N</div> <div>N</div>
</th> </th>
)} )}
</> </>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, idx) => { {rows.map((row, idx) => {
if (row.type === 'header') { if (row.type === 'header') {
const noneColCount = Object.values(categories).filter(c => c.required).length const noneColCount = Object.values(categories).filter(c => c.required).length
const totalCols = data.flags.length + noneColCount + 1 const totalCols = data.flags.length + noneColCount + 1
return ( return (
<tr key={`h-${idx}`}> <tr key={`h-${idx}`}>
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td> <td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
</tr> </tr>
) )
} }
const ing = row.ingredient! const ing = row.ingredient!
return ( return (
<tr key={ing.ingredient_id + '-' + idx}> <tr key={ing.ingredient_id + '-' + idx}>
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td> <td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
{Object.entries(categories).map(([catId, cat]) => ( {Object.entries(categories).map(([catId, cat]) => (
<> <>
{cat.flags.map(f => renderCell(ing.ingredient_id, f.id, ing.flags[f.id], cat.propagation, f.name))} {cat.flags.map(f => renderCell(ing.ingredient_id, f.id, ing.flags[f.id], cat.propagation, f.name))}
{cat.required && ( {cat.required && (
<td <td
key={`none-${catId}-${ing.ingredient_id}`} key={`none-${catId}-${ing.ingredient_id}`}
style={{ style={{
...styles.cell, ...styles.cell,
borderLeft: '2px solid #e0e0e0', borderLeft: '2px solid #e0e0e0',
cursor: 'pointer', cursor: 'pointer',
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd', color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined, background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
fontWeight: getNoneForCategory(ing, Number(catId)) ? 600 : 400, fontWeight: getNoneForCategory(ing, Number(catId)) ? 600 : 400,
}} }}
title={getNoneForCategory(ing, Number(catId)) ? `None apply \u2014 click to unset` : `Click to mark "None apply" for ${cat.name}`} title={getNoneForCategory(ing, Number(catId)) ? `None apply \u2014 click to unset` : `Click to mark "None apply" for ${cat.name}`}
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })} onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
> >
N N
</td> </td>
)} )}
</> </>
))} ))}
</tr> </tr>
) )
})} })}
{/* Total row */} {/* Total row */}
<tr style={{ borderTop: '3px double #333' }}> <tr style={{ borderTop: '3px double #333' }}>
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td> <td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
{Object.entries(categories).map(([catId, cat]) => ( {Object.entries(categories).map(([catId, cat]) => (
<> <>
{cat.flags.map(f => { {cat.flags.map(f => {
const t = totals[f.id] const t = totals[f.id]
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td> if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
if (cat.propagation === 'contains') { if (cat.propagation === 'contains') {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}> return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
{t.has ? '\u2713' : ''} {t.has ? '\u2713' : ''}
</td> </td>
} else { } else {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}> return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
{t.has ? '\u2713' : '\u2717'} {t.has ? '\u2713' : '\u2717'}
</td> </td>
} }
})} })}
{cat.required && ( {cat.required && (
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td> <td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
)} )}
</> </>
))} ))}
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
container: { background: 'white', padding: '1rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' }, container: { background: 'white', padding: '1rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '0.8rem' }, table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '0.8rem' },
th: { padding: '0.4rem 0.35rem', textAlign: 'center' as const, borderBottom: '2px solid #ddd', fontSize: '0.7rem', fontWeight: 600, minWidth: '40px', position: 'sticky' as const, top: 0, background: 'white', zIndex: 1 }, th: { padding: '0.4rem 0.35rem', textAlign: 'center' as const, borderBottom: '2px solid #ddd', fontSize: '0.7rem', fontWeight: 600, minWidth: '40px', position: 'sticky' as const, top: 0, background: 'white', zIndex: 1 },
td: { padding: '0.35rem 0.5rem', borderBottom: '1px solid #f0f0f0', fontSize: '0.8rem' }, td: { padding: '0.35rem 0.5rem', borderBottom: '1px solid #f0f0f0', fontSize: '0.8rem' },
cell: { padding: '0.35rem', textAlign: 'center' as const, borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem' }, cell: { padding: '0.35rem', textAlign: 'center' as const, borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem' },
subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' }, subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' },
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -542,7 +542,7 @@ export default function Review() {
queryKey: ['invoice', id], queryKey: ['invoice', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}`, { const res = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch invoice') if (!res.ok) throw new Error('Failed to fetch invoice')
return res.json() return res.json()
@ -553,14 +553,14 @@ export default function Review() {
// Direct URL with token - simpler approach // Direct URL with token - simpler approach
const imageUrl = invoice const imageUrl = invoice
? `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}#toolbar=0&navpanes=0&view=FitH` ? `/kitchen/api/invoices/${id}/file#toolbar=0&navpanes=0&view=FitH`
: null : null
const { data: lineItems, refetch: refetchLineItems } = useQuery<LineItem[]>({ const { data: lineItems, refetch: refetchLineItems } = useQuery<LineItem[]>({
queryKey: ['invoice-line-items', id], queryKey: ['invoice-line-items', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch line items') if (!res.ok) throw new Error('Failed to fetch line items')
return res.json() return res.json()
@ -577,7 +577,7 @@ export default function Review() {
queryKey: ['invoice-stock-history', id], queryKey: ['invoice-stock-history', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}/stock-history`, { const res = await fetch(`/kitchen/api/invoices/${id}/stock-history`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch stock history') if (!res.ok) throw new Error('Failed to fetch stock history')
return res.json() return res.json()
@ -589,7 +589,7 @@ export default function Review() {
queryKey: ['invoice-duplicates', id], queryKey: ['invoice-duplicates', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}/duplicates`, { const res = await fetch(`/kitchen/api/invoices/${id}/duplicates`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch duplicates') if (!res.ok) throw new Error('Failed to fetch duplicates')
return res.json() return res.json()
@ -601,7 +601,7 @@ export default function Review() {
queryKey: ['invoice-ocr-data', id], queryKey: ['invoice-ocr-data', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}/ocr-data`, { const res = await fetch(`/kitchen/api/invoices/${id}/ocr-data`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch OCR data') if (!res.ok) throw new Error('Failed to fetch OCR data')
return res.json() return res.json()
@ -613,7 +613,7 @@ export default function Review() {
queryKey: ['settings'], queryKey: ['settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch settings') if (!res.ok) throw new Error('Failed to fetch settings')
return res.json() return res.json()
@ -639,7 +639,7 @@ export default function Review() {
fetch('/kitchen/api/ingredients/sources/alias-suggestions', { fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: parseInt(supplierId), items: uniqueItems }), body: JSON.stringify({ supplier_id: parseInt(supplierId), items: uniqueItems }),
}) })
.then(res => res.ok ? res.json() : []) .then(res => res.ok ? res.json() : [])
@ -886,7 +886,7 @@ export default function Review() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -916,7 +916,7 @@ export default function Review() {
queryKey: ['po-match', id], queryKey: ['po-match', id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/purchase-orders/matching/for-invoice?invoice_id=${id}`, { const res = await fetch(`/kitchen/api/purchase-orders/matching/for-invoice?invoice_id=${id}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { matches: [], linked_po: null } if (!res.ok) return { matches: [], linked_po: null }
return res.json() return res.json()
@ -945,7 +945,7 @@ export default function Review() {
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
try { try {
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, { const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (checkRes.ok) { if (checkRes.ok) {
const inv = await checkRes.json() const inv = await checkRes.json()
@ -971,8 +971,8 @@ export default function Review() {
try { try {
// Fetch the PDF // Fetch the PDF
const pdfUrl = `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token)}` const pdfUrl = `/kitchen/api/invoices/${id}/file`
const response = await fetch(pdfUrl) const response = await fetch(pdfUrl, { credentials: 'include' })
const arrayBuffer = await response.arrayBuffer() const arrayBuffer = await response.arrayBuffer()
// Load the PDF document // Load the PDF document
@ -1043,8 +1043,8 @@ export default function Review() {
mutationFn: async (data: Partial<Invoice>) => { mutationFn: async (data: Partial<Invoice>) => {
const res = await fetch(`/kitchen/api/invoices/${id}`, { const res = await fetch(`/kitchen/api/invoices/${id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1069,7 +1069,7 @@ export default function Review() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}`, { const res = await fetch(`/kitchen/api/invoices/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete') if (!res.ok) throw new Error('Failed to delete')
return res.json() return res.json()
@ -1084,8 +1084,8 @@ export default function Review() {
mutationFn: async ({ itemId, data }: { itemId: number; data: Partial<LineItem> }) => { mutationFn: async ({ itemId, data }: { itemId: number; data: Partial<LineItem> }) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1106,8 +1106,8 @@ export default function Review() {
mutationFn: async (data: Partial<LineItem>) => { mutationFn: async (data: Partial<LineItem>) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1127,8 +1127,8 @@ export default function Review() {
mutationFn: async (itemId: number) => { mutationFn: async (itemId: number) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
}, },
}) })
if (!res.ok) throw new Error('Failed to delete line item') if (!res.ok) throw new Error('Failed to delete line item')
@ -1145,8 +1145,8 @@ export default function Review() {
mutationFn: async ({ itemId, portionDesc }: { itemId: number; portionDesc?: string }) => { mutationFn: async ({ itemId, portionDesc }: { itemId: number; portionDesc?: string }) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ portion_description: portionDesc || null }), body: JSON.stringify({ portion_description: portionDesc || null }),
@ -1163,8 +1163,8 @@ export default function Review() {
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
@ -1184,8 +1184,8 @@ export default function Review() {
mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => { mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => {
const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, { const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ alias, invoice_id: invoiceId }), body: JSON.stringify({ alias, invoice_id: invoiceId }),
@ -1203,8 +1203,8 @@ export default function Review() {
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => { mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, { const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ alias }), body: JSON.stringify({ alias }),
@ -1230,7 +1230,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, { const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: parseInt(id!) }), body: JSON.stringify({ invoice_id: parseInt(id!) }),
}) })
if (!res.ok) throw new Error('Failed to link PO') if (!res.ok) throw new Error('Failed to link PO')
@ -1244,7 +1244,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, { const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to unlink PO') if (!res.ok) throw new Error('Failed to unlink PO')
refetchPoMatch() refetchPoMatch()
@ -1388,7 +1388,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, { const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
@ -1425,7 +1425,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, { const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
@ -1458,7 +1458,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, { const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
@ -1486,7 +1486,7 @@ export default function Review() {
setShowInvoiceNumberModal(true) setShowInvoiceNumberModal(true)
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/parse-invoice-number`, { const res = await fetch(`/kitchen/api/invoices/${id}/parse-invoice-number`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to search for invoice number') if (!res.ok) throw new Error('Failed to search for invoice number')
const data = await res.json() const data = await res.json()
@ -1515,7 +1515,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, { const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
@ -1533,7 +1533,7 @@ export default function Review() {
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
try { try {
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, { const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (checkRes.ok) { if (checkRes.ok) {
const inv = await checkRes.json() const inv = await checkRes.json()
@ -1567,7 +1567,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, { const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
@ -1758,9 +1758,9 @@ export default function Review() {
try { try {
const res = await fetch('/kitchen/api/invoices/line-items/search', { const res = await fetch('/kitchen/api/invoices/line-items/search', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
query, query,
@ -1783,8 +1783,8 @@ export default function Review() {
const promises = lineItems.map(item => const promises = lineItems.map(item =>
fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, { fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ is_non_stock: !markAsStock }), body: JSON.stringify({ is_non_stock: !markAsStock }),
@ -1806,7 +1806,7 @@ export default function Review() {
setAiMatchResults([]) setAiMatchResults([])
try { try {
const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, { const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -1829,7 +1829,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, { const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
let detail = 'AI analysis failed' let detail = 'AI analysis failed'
@ -1907,7 +1907,7 @@ export default function Review() {
setDefinitionLoading(true) setDefinitionLoading(true)
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) { if (res.ok) {
const def = await res.json() const def = await res.json()
@ -1928,7 +1928,7 @@ export default function Review() {
setAiPackSizeLoading(true) setAiPackSizeLoading(true)
try { try {
const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, { const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (packRes.ok) { if (packRes.ok) {
const packData = await packRes.json() const packData = await packRes.json()
@ -1962,7 +1962,7 @@ export default function Review() {
setIngredientSearchLoading(true) setIngredientSearchLoading(true)
try { try {
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, { const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -2055,7 +2055,7 @@ export default function Review() {
// Set ingredient_id on line item // Set ingredient_id on line item
await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: selectedIngredientId }), body: JSON.stringify({ ingredient_id: selectedIngredientId }),
}) })
@ -2083,7 +2083,7 @@ export default function Review() {
} }
const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, { const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData), body: JSON.stringify(sourceData),
}) })
if (!srcRes.ok) { if (!srcRes.ok) {
@ -2610,7 +2610,7 @@ export default function Review() {
)} )}
{isPDF && imageUrl && ( {isPDF && imageUrl && (
<a <a
href={`/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}`} href={`/kitchen/api/invoices/${id}/file`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={styles.openPdfLink} style={styles.openPdfLink}
@ -3094,9 +3094,9 @@ export default function Review() {
try { try {
await fetch(`/kitchen/api/invoices/${id}`, { await fetch(`/kitchen/api/invoices/${id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ notes: invoiceNotes }) body: JSON.stringify({ notes: invoiceNotes })
}) })
@ -4560,7 +4560,7 @@ export default function Review() {
width: '100%', width: '100%',
}}> }}>
<img <img
src={`${imageUrl.split('?')[0]}?token=${encodeURIComponent(token || '')}`} src={`${imageUrl.split('?')[0]}`}
alt="Line item location" alt="Line item location"
style={{ style={{
width: '100%', width: '100%',
@ -4675,7 +4675,7 @@ export default function Review() {
try { try {
const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, { const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const result = await res.json() const result = await res.json()

View file

@ -1,448 +1,448 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../App' import { useAuth } from '../App'
interface SalesGPItem { interface SalesGPItem {
menu_item_name: string menu_item_name: string
portion_name: string portion_name: string
category: string category: string
total_qty: number total_qty: number
total_revenue_net: number total_revenue_net: number
dbb_qty: number dbb_qty: number
recipe_id: number | null recipe_id: number | null
recipe_name: string | null recipe_name: string | null
dish_course: string | null dish_course: string | null
cost_per_portion: number | null cost_per_portion: number | null
total_cost: number | null total_cost: number | null
item_gp_percent: number | null item_gp_percent: number | null
} }
interface SalesGPCourseGroup { interface SalesGPCourseGroup {
course_name: string course_name: string
items: SalesGPItem[] items: SalesGPItem[]
course_revenue: number course_revenue: number
course_cost: number course_cost: number
course_gp_percent: number | null course_gp_percent: number | null
} }
interface SalesGPResponse { interface SalesGPResponse {
from_date: string from_date: string
to_date: string to_date: string
courses: SalesGPCourseGroup[] courses: SalesGPCourseGroup[]
unmapped_items: SalesGPItem[] unmapped_items: SalesGPItem[]
mapped_revenue_net: number mapped_revenue_net: number
mapped_total_cost: number mapped_total_cost: number
mapped_gp_percent: number | null mapped_gp_percent: number | null
total_all_revenue_net: number total_all_revenue_net: number
unmapped_revenue_net: number unmapped_revenue_net: number
mapped_revenue_percent: number mapped_revenue_percent: number
mapped_item_count: number mapped_item_count: number
unmapped_item_count: number unmapped_item_count: number
} }
interface DishRecipe { interface DishRecipe {
id: number id: number
name: string name: string
menu_section_name: string | null menu_section_name: string | null
cost_per_portion: number | null cost_per_portion: number | null
kds_menu_item_name: string | null kds_menu_item_name: string | null
sambapos_portion_name: string | null sambapos_portion_name: string | null
} }
export default function SalesGPReport() { export default function SalesGPReport() {
const { token } = useAuth() const { token } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Date range // Date range
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10) const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10)
const [fromDate, setFromDate] = useState(weekAgo) const [fromDate, setFromDate] = useState(weekAgo)
const [toDate, setToDate] = useState(today) const [toDate, setToDate] = useState(today)
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const [submittedFrom, setSubmittedFrom] = useState(weekAgo) const [submittedFrom, setSubmittedFrom] = useState(weekAgo)
const [submittedTo, setSubmittedTo] = useState(today) const [submittedTo, setSubmittedTo] = useState(today)
// Collapsed courses // Collapsed courses
const [collapsedCourses, setCollapsedCourses] = useState<Set<string>>(new Set()) const [collapsedCourses, setCollapsedCourses] = useState<Set<string>>(new Set())
// Mapping modal // Mapping modal
const [mappingItem, setMappingItem] = useState<SalesGPItem | null>(null) const [mappingItem, setMappingItem] = useState<SalesGPItem | null>(null)
const [recipeSearch, setRecipeSearch] = useState('') const [recipeSearch, setRecipeSearch] = useState('')
// Fetch report data // Fetch report data
const { data: report, isLoading, error } = useQuery<SalesGPResponse>({ const { data: report, isLoading, error } = useQuery<SalesGPResponse>({
queryKey: ['sales-gp', submittedFrom, submittedTo], queryKey: ['sales-gp', submittedFrom, submittedTo],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/sales-gp?from_date=${submittedFrom}&to_date=${submittedTo}`, { const res = await fetch(`/kitchen/api/reports/sales-gp?from_date=${submittedFrom}&to_date=${submittedTo}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' })) const err = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(err.detail || 'Request failed') throw new Error(err.detail || 'Request failed')
} }
return res.json() return res.json()
}, },
enabled: !!token && submitted, enabled: !!token && submitted,
}) })
// Fetch dish recipes for mapping modal // Fetch dish recipes for mapping modal
const { data: dishRecipes } = useQuery<DishRecipe[]>({ const { data: dishRecipes } = useQuery<DishRecipe[]>({
queryKey: ['recipes-for-mapping', recipeSearch], queryKey: ['recipes-for-mapping', recipeSearch],
queryFn: async () => { queryFn: async () => {
const url = `/kitchen/api/recipes?recipe_type=dish${recipeSearch ? `&search=${encodeURIComponent(recipeSearch)}` : ''}` const url = `/kitchen/api/recipes?recipe_type=dish${recipeSearch ? `&search=${encodeURIComponent(recipeSearch)}` : ''}`
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) const res = await fetch(url, { credentials: 'include' })
return res.json() return res.json()
}, },
enabled: !!token && !!mappingItem, enabled: !!token && !!mappingItem,
}) })
// Map unmapped item to recipe // Map unmapped item to recipe
const mapMutation = useMutation({ const mapMutation = useMutation({
mutationFn: async ({ recipeId, menuItemName, portionName }: { recipeId: number; menuItemName: string; portionName: string }) => { mutationFn: async ({ recipeId, menuItemName, portionName }: { recipeId: number; menuItemName: string; portionName: string }) => {
const res = await fetch(`/kitchen/api/recipes/${recipeId}`, { const res = await fetch(`/kitchen/api/recipes/${recipeId}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
kds_menu_item_name: menuItemName, kds_menu_item_name: menuItemName,
sambapos_portion_name: portionName === 'Normal' ? null : portionName, sambapos_portion_name: portionName === 'Normal' ? null : portionName,
}), }),
}) })
if (!res.ok) throw new Error('Failed to update recipe') if (!res.ok) throw new Error('Failed to update recipe')
}, },
onSuccess: () => { onSuccess: () => {
setMappingItem(null) setMappingItem(null)
setRecipeSearch('') setRecipeSearch('')
queryClient.invalidateQueries({ queryKey: ['sales-gp'] }) queryClient.invalidateQueries({ queryKey: ['sales-gp'] })
}, },
}) })
const handleGenerate = () => { const handleGenerate = () => {
setSubmittedFrom(fromDate) setSubmittedFrom(fromDate)
setSubmittedTo(toDate) setSubmittedTo(toDate)
setSubmitted(true) setSubmitted(true)
} }
const toggleCourse = (name: string) => { const toggleCourse = (name: string) => {
setCollapsedCourses(prev => { setCollapsedCourses(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(name)) next.delete(name) if (next.has(name)) next.delete(name)
else next.add(name) else next.add(name)
return next return next
}) })
} }
const fmt = (n: number) => Number(n).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const fmt = (n: number) => Number(n).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const fmtPct = (n: number | null) => n != null ? `${Number(n).toFixed(1)}%` : '—' const fmtPct = (n: number | null) => n != null ? `${Number(n).toFixed(1)}%` : '—'
const gpColor = (pct: number | null) => { const gpColor = (pct: number | null) => {
if (pct == null) return '#888' if (pct == null) return '#888'
if (pct >= 70) return '#16a34a' if (pct >= 70) return '#16a34a'
if (pct >= 60) return '#ca8a04' if (pct >= 60) return '#ca8a04'
return '#dc2626' return '#dc2626'
} }
return ( return (
<div style={styles.container}> <div style={styles.container}>
<h2 style={styles.pageTitle}>Sales GP% Report</h2> <h2 style={styles.pageTitle}>Sales GP% Report</h2>
{/* Date range selector */} {/* Date range selector */}
<div style={styles.dateBar}> <div style={styles.dateBar}>
<div style={styles.dateGroup}> <div style={styles.dateGroup}>
<label style={styles.dateLabel}>From</label> <label style={styles.dateLabel}>From</label>
<input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} style={styles.dateInput} /> <input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} style={styles.dateInput} />
</div> </div>
<div style={styles.dateGroup}> <div style={styles.dateGroup}>
<label style={styles.dateLabel}>To</label> <label style={styles.dateLabel}>To</label>
<input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} style={styles.dateInput} /> <input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} style={styles.dateInput} />
</div> </div>
<button onClick={handleGenerate} style={styles.generateBtn}>Generate</button> <button onClick={handleGenerate} style={styles.generateBtn}>Generate</button>
</div> </div>
{isLoading && <div style={styles.loading}>Loading sales data from SambaPOS...</div>} {isLoading && <div style={styles.loading}>Loading sales data from SambaPOS...</div>}
{error && <div style={styles.error}>{(error as Error).message}</div>} {error && <div style={styles.error}>{(error as Error).message}</div>}
{report && ( {report && (
<> <>
{/* Summary banner */} {/* Summary banner */}
<div style={styles.summaryBanner}> <div style={styles.summaryBanner}>
<div style={styles.summaryMain}> <div style={styles.summaryMain}>
<div style={styles.summaryLabel}>Estimated Sales GP%</div> <div style={styles.summaryLabel}>Estimated Sales GP%</div>
<div style={{ ...styles.summaryValue, color: gpColor(report.mapped_gp_percent) }}> <div style={{ ...styles.summaryValue, color: gpColor(report.mapped_gp_percent) }}>
{fmtPct(report.mapped_gp_percent)} {fmtPct(report.mapped_gp_percent)}
</div> </div>
<div style={styles.summarySubtext}>mapped items only</div> <div style={styles.summarySubtext}>mapped items only</div>
</div> </div>
<div style={styles.summaryStats}> <div style={styles.summaryStats}>
<div style={styles.statBox}> <div style={styles.statBox}>
<div style={styles.statLabel}>Mapped Revenue</div> <div style={styles.statLabel}>Mapped Revenue</div>
<div style={styles.statValue}>&pound;{fmt(report.mapped_revenue_net)}</div> <div style={styles.statValue}>&pound;{fmt(report.mapped_revenue_net)}</div>
</div> </div>
<div style={styles.statBox}> <div style={styles.statBox}>
<div style={styles.statLabel}>Mapped Cost</div> <div style={styles.statLabel}>Mapped Cost</div>
<div style={styles.statValue}>&pound;{fmt(report.mapped_total_cost)}</div> <div style={styles.statValue}>&pound;{fmt(report.mapped_total_cost)}</div>
</div> </div>
<div style={styles.statBox}> <div style={styles.statBox}>
<div style={styles.statLabel}>Total Revenue</div> <div style={styles.statLabel}>Total Revenue</div>
<div style={styles.statValue}>&pound;{fmt(report.total_all_revenue_net)}</div> <div style={styles.statValue}>&pound;{fmt(report.total_all_revenue_net)}</div>
</div> </div>
</div> </div>
{/* Coverage bar */} {/* Coverage bar */}
<div style={styles.coverageSection}> <div style={styles.coverageSection}>
<div style={styles.coverageLabel}> <div style={styles.coverageLabel}>
Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed
<span style={{ color: '#888', marginLeft: '0.5rem' }}> <span style={{ color: '#888', marginLeft: '0.5rem' }}>
({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped) ({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped)
</span> </span>
</div> </div>
<div style={styles.coverageBarBg}> <div style={styles.coverageBarBg}>
<div style={{ ...styles.coverageBarFill, width: `${Math.min(Number(report.mapped_revenue_percent), 100)}%` }} /> <div style={{ ...styles.coverageBarFill, width: `${Math.min(Number(report.mapped_revenue_percent), 100)}%` }} />
</div> </div>
</div> </div>
</div> </div>
{/* Course sections */} {/* Course sections */}
{report.courses.map(course => ( {report.courses.map(course => (
<div key={course.course_name} style={styles.courseSection}> <div key={course.course_name} style={styles.courseSection}>
<div style={styles.courseHeader} onClick={() => toggleCourse(course.course_name)}> <div style={styles.courseHeader} onClick={() => toggleCourse(course.course_name)}>
<div style={styles.courseTitle}> <div style={styles.courseTitle}>
<span style={styles.collapseIcon}>{collapsedCourses.has(course.course_name) ? '▸' : '▾'}</span> <span style={styles.collapseIcon}>{collapsedCourses.has(course.course_name) ? '▸' : '▾'}</span>
{course.course_name} {course.course_name}
</div> </div>
<div style={styles.courseStats}> <div style={styles.courseStats}>
<span style={{ marginRight: '1.5rem' }}>Revenue: &pound;{fmt(course.course_revenue)}</span> <span style={{ marginRight: '1.5rem' }}>Revenue: &pound;{fmt(course.course_revenue)}</span>
<span style={{ marginRight: '1.5rem' }}>Cost: &pound;{fmt(course.course_cost)}</span> <span style={{ marginRight: '1.5rem' }}>Cost: &pound;{fmt(course.course_cost)}</span>
<span style={{ fontWeight: 700, color: gpColor(course.course_gp_percent) }}> <span style={{ fontWeight: 700, color: gpColor(course.course_gp_percent) }}>
GP: {fmtPct(course.course_gp_percent)} GP: {fmtPct(course.course_gp_percent)}
</span> </span>
</div> </div>
</div> </div>
{!collapsedCourses.has(course.course_name) && ( {!collapsedCourses.has(course.course_name) && (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Item</th> <th style={styles.th}>Item</th>
<th style={styles.th}>Portion</th> <th style={styles.th}>Portion</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th> <th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th> <th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Cost/Portion</th> <th style={{ ...styles.th, textAlign: 'right' }}>Cost/Portion</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Total Cost</th> <th style={{ ...styles.th, textAlign: 'right' }}>Total Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>GP%</th> <th style={{ ...styles.th, textAlign: 'right' }}>GP%</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{course.items.map((item, idx) => ( {course.items.map((item, idx) => (
<tr key={idx} style={styles.tr}> <tr key={idx} style={styles.tr}>
<td style={styles.td}> <td style={styles.td}>
{item.recipe_id ? ( {item.recipe_id ? (
<span <span
title={`Recipe: ${item.recipe_name}`} title={`Recipe: ${item.recipe_name}`}
onClick={() => navigate(`/dishes/${item.recipe_id}`)} onClick={() => navigate(`/dishes/${item.recipe_id}`)}
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }} style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
>{item.menu_item_name}</span> >{item.menu_item_name}</span>
) : item.menu_item_name} ) : item.menu_item_name}
</td> </td>
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}> <td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
{item.portion_name === 'Normal' ? '—' : item.portion_name} {item.portion_name === 'Normal' ? '—' : item.portion_name}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_qty} {item.total_qty}
{item.dbb_qty > 0 && ( {item.dbb_qty > 0 && (
<span <span
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used for GP calculation`} title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used for GP calculation`}
style={styles.dbbBadge} style={styles.dbbBadge}
> >
{item.dbb_qty} DBB {item.dbb_qty} DBB
</span> </span>
)} )}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td> <td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'} {item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'} {item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: gpColor(item.item_gp_percent) }}> <td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: gpColor(item.item_gp_percent) }}>
{fmtPct(item.item_gp_percent)} {fmtPct(item.item_gp_percent)}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
))} ))}
{/* Unmapped items section */} {/* Unmapped items section */}
{report.unmapped_items.length > 0 && ( {report.unmapped_items.length > 0 && (
<div style={styles.unmappedSection}> <div style={styles.unmappedSection}>
<div style={styles.unmappedHeader}> <div style={styles.unmappedHeader}>
<span>Unmapped Items</span> <span>Unmapped Items</span>
<span style={styles.unmappedSubtext}> <span style={styles.unmappedSubtext}>
&pound;{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales) &pound;{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales)
</span> </span>
</div> </div>
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Item</th> <th style={styles.th}>Item</th>
<th style={styles.th}>Portion</th> <th style={styles.th}>Portion</th>
<th style={styles.th}>Category</th> <th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th> <th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th> <th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Action</th> <th style={{ ...styles.th, textAlign: 'center' }}>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{report.unmapped_items.map((item, idx) => ( {report.unmapped_items.map((item, idx) => (
<tr key={idx} style={styles.tr}> <tr key={idx} style={styles.tr}>
<td style={styles.td}>{item.menu_item_name}</td> <td style={styles.td}>{item.menu_item_name}</td>
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}> <td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
{item.portion_name === 'Normal' ? '—' : item.portion_name} {item.portion_name === 'Normal' ? '—' : item.portion_name}
</td> </td>
<td style={{ ...styles.td, color: '#888' }}>{item.category}</td> <td style={{ ...styles.td, color: '#888' }}>{item.category}</td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_qty} {item.total_qty}
{item.dbb_qty > 0 && ( {item.dbb_qty > 0 && (
<span <span
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used`} title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used`}
style={styles.dbbBadge} style={styles.dbbBadge}
> >
{item.dbb_qty} DBB {item.dbb_qty} DBB
</span> </span>
)} )}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td> <td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td>
<td style={{ ...styles.td, textAlign: 'center' }}> <td style={{ ...styles.td, textAlign: 'center' }}>
<button <button
onClick={() => { setMappingItem(item); setRecipeSearch('') }} onClick={() => { setMappingItem(item); setRecipeSearch('') }}
style={styles.mapBtn} style={styles.mapBtn}
>Map</button> >Map</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</> </>
)} )}
{/* Recipe mapping modal */} {/* Recipe mapping modal */}
{mappingItem && ( {mappingItem && (
<div style={styles.overlay}> <div style={styles.overlay}>
<div style={styles.modal}> <div style={styles.modal}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}> <h3 style={{ margin: 0 }}>
Map: {mappingItem.menu_item_name} Map: {mappingItem.menu_item_name}
{mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`} {mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`}
</h3> </h3>
<button onClick={() => { setMappingItem(null); setRecipeSearch('') }} style={styles.closeBtn}></button> <button onClick={() => { setMappingItem(null); setRecipeSearch('') }} style={styles.closeBtn}></button>
</div> </div>
<div style={styles.modalBody}> <div style={styles.modalBody}>
<input <input
value={recipeSearch} value={recipeSearch}
onChange={(e) => setRecipeSearch(e.target.value)} onChange={(e) => setRecipeSearch(e.target.value)}
style={{ ...styles.searchInput, marginBottom: '0.75rem' }} style={{ ...styles.searchInput, marginBottom: '0.75rem' }}
placeholder="Search dish recipes..." placeholder="Search dish recipes..."
autoFocus autoFocus
/> />
{!dishRecipes ? ( {!dishRecipes ? (
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>Loading recipes...</div> <div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>Loading recipes...</div>
) : dishRecipes.length === 0 ? ( ) : dishRecipes.length === 0 ? (
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>No dish recipes found.</div> <div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>No dish recipes found.</div>
) : ( ) : (
<div style={{ maxHeight: '350px', overflow: 'auto' }}> <div style={{ maxHeight: '350px', overflow: 'auto' }}>
{dishRecipes.map(r => ( {dishRecipes.map(r => (
<div <div
key={r.id} key={r.id}
onClick={() => mapMutation.mutate({ onClick={() => mapMutation.mutate({
recipeId: r.id, recipeId: r.id,
menuItemName: mappingItem.menu_item_name, menuItemName: mappingItem.menu_item_name,
portionName: mappingItem.portion_name, portionName: mappingItem.portion_name,
})} })}
style={styles.recipeRow} style={styles.recipeRow}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f0f7ff')} onMouseEnter={(e) => (e.currentTarget.style.background = '#f0f7ff')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
> >
<div> <div>
<div style={{ fontWeight: 600 }}>{r.name}</div> <div style={{ fontWeight: 600 }}>{r.name}</div>
<div style={{ fontSize: '0.75rem', color: '#888' }}> <div style={{ fontSize: '0.75rem', color: '#888' }}>
{r.menu_section_name || 'No course'} {r.menu_section_name || 'No course'}
{r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`} {r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`}
</div> </div>
</div> </div>
{r.kds_menu_item_name && ( {r.kds_menu_item_name && (
<div style={{ fontSize: '0.7rem', color: '#999' }}> <div style={{ fontSize: '0.7rem', color: '#999' }}>
Already mapped: {r.kds_menu_item_name} Already mapped: {r.kds_menu_item_name}
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' }, container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' },
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' }, pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' }, dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' },
dateGroup: { display: 'flex', flexDirection: 'column', gap: '0.2rem' }, dateGroup: { display: 'flex', flexDirection: 'column', gap: '0.2rem' },
dateLabel: { fontSize: '0.75rem', fontWeight: 600, color: '#666' }, dateLabel: { fontSize: '0.75rem', fontWeight: 600, color: '#666' },
dateInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' }, dateInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
generateBtn: { padding: '0.5rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }, generateBtn: { padding: '0.5rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
loading: { padding: '2rem', textAlign: 'center', color: '#888' }, loading: { padding: '2rem', textAlign: 'center', color: '#888' },
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' }, error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
// Summary // Summary
summaryBanner: { background: '#f8f9fa', padding: '1.25rem', borderRadius: '8px', border: '2px solid #e0e0e0', marginBottom: '1.5rem' }, summaryBanner: { background: '#f8f9fa', padding: '1.25rem', borderRadius: '8px', border: '2px solid #e0e0e0', marginBottom: '1.5rem' },
summaryMain: { textAlign: 'center', marginBottom: '1rem' }, summaryMain: { textAlign: 'center', marginBottom: '1rem' },
summaryLabel: { fontSize: '0.85rem', fontWeight: 600, color: '#666', textTransform: 'uppercase' }, summaryLabel: { fontSize: '0.85rem', fontWeight: 600, color: '#666', textTransform: 'uppercase' },
summaryValue: { fontSize: '2.5rem', fontWeight: 800, lineHeight: 1.2 }, summaryValue: { fontSize: '2.5rem', fontWeight: 800, lineHeight: 1.2 },
summarySubtext: { fontSize: '0.75rem', color: '#999' }, summarySubtext: { fontSize: '0.75rem', color: '#999' },
summaryStats: { display: 'flex', justifyContent: 'center', gap: '2rem', marginBottom: '1rem', flexWrap: 'wrap' }, summaryStats: { display: 'flex', justifyContent: 'center', gap: '2rem', marginBottom: '1rem', flexWrap: 'wrap' },
statBox: { textAlign: 'center' }, statBox: { textAlign: 'center' },
statLabel: { fontSize: '0.75rem', color: '#666', fontWeight: 600 }, statLabel: { fontSize: '0.75rem', color: '#666', fontWeight: 600 },
statValue: { fontSize: '1.1rem', fontWeight: 700 }, statValue: { fontSize: '1.1rem', fontWeight: 700 },
coverageSection: { borderTop: '1px solid #e0e0e0', paddingTop: '0.75rem' }, coverageSection: { borderTop: '1px solid #e0e0e0', paddingTop: '0.75rem' },
coverageLabel: { fontSize: '0.8rem', color: '#555', marginBottom: '0.4rem' }, coverageLabel: { fontSize: '0.8rem', color: '#555', marginBottom: '0.4rem' },
coverageBarBg: { height: '8px', background: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' }, coverageBarBg: { height: '8px', background: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' },
coverageBarFill: { height: '100%', background: '#16a34a', borderRadius: '4px', transition: 'width 0.3s' }, coverageBarFill: { height: '100%', background: '#16a34a', borderRadius: '4px', transition: 'width 0.3s' },
// Course sections // Course sections
courseSection: { marginBottom: '1rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' }, courseSection: { marginBottom: '1rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' },
courseHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', flexWrap: 'wrap', gap: '0.5rem' }, courseHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', flexWrap: 'wrap', gap: '0.5rem' },
courseTitle: { fontWeight: 700, fontSize: '1rem' }, courseTitle: { fontWeight: 700, fontSize: '1rem' },
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' }, collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
courseStats: { fontSize: '0.85rem', color: '#555' }, courseStats: { fontSize: '0.85rem', color: '#555' },
table: { width: '100%', borderCollapse: 'collapse' }, table: { width: '100%', borderCollapse: 'collapse' },
th: { padding: '0.5rem 0.75rem', textAlign: 'left', borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666', background: '#fafafa' }, th: { padding: '0.5rem 0.75rem', textAlign: 'left', borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666', background: '#fafafa' },
tr: { borderBottom: '1px solid #f0f0f0' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.4rem 0.75rem', fontSize: '0.85rem' }, td: { padding: '0.4rem 0.75rem', fontSize: '0.85rem' },
// Unmapped // Unmapped
unmappedSection: { marginTop: '1.5rem', border: '1px solid #f0c040', borderRadius: '8px', overflow: 'hidden' }, unmappedSection: { marginTop: '1.5rem', border: '1px solid #f0c040', borderRadius: '8px', overflow: 'hidden' },
unmappedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fffbeb', fontWeight: 700, fontSize: '1rem', flexWrap: 'wrap', gap: '0.5rem' }, unmappedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fffbeb', fontWeight: 700, fontSize: '1rem', flexWrap: 'wrap', gap: '0.5rem' },
unmappedSubtext: { fontSize: '0.85rem', fontWeight: 400, color: '#92400e' }, unmappedSubtext: { fontSize: '0.85rem', fontWeight: 400, color: '#92400e' },
mapBtn: { padding: '0.25rem 0.75rem', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }, mapBtn: { padding: '0.25rem 0.75rem', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 },
// DBB badge // DBB badge
dbbBadge: { display: 'inline-block', marginLeft: '0.35rem', padding: '0.1rem 0.35rem', background: '#ede9fe', color: '#6d28d9', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'default', whiteSpace: 'nowrap' as const }, dbbBadge: { display: 'inline-block', marginLeft: '0.35rem', padding: '0.1rem 0.35rem', background: '#ede9fe', color: '#6d28d9', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'default', whiteSpace: 'nowrap' as const },
// Modal // Modal
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: 'white', borderRadius: '10px', width: '500px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' }, modal: { background: 'white', borderRadius: '10px', width: '500px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' }, modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
modalBody: { padding: '1.25rem' }, modalBody: { padding: '1.25rem' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' }, closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
searchInput: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' }, searchInput: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' },
recipeRow: { padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, recipeRow: { padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
} }

View file

@ -105,7 +105,7 @@ export default function SearchDefinitions() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
@ -124,12 +124,12 @@ export default function SearchDefinitions() {
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`/kitchen/api/search/definitions?${params}`, { const res = await fetch(`/kitchen/api/search/definitions?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Search failed') if (!res.ok) throw new Error('Search failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch invoice line items when editing (to find matching line item for bounding box) // Fetch invoice line items when editing (to find matching line item for bounding box)
@ -137,7 +137,7 @@ export default function SearchDefinitions() {
queryKey: ['invoice-line-items', editingDef?.source_invoice_id], queryKey: ['invoice-line-items', editingDef?.source_invoice_id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/line-items`, { const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/line-items`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch line items') if (!res.ok) throw new Error('Failed to fetch line items')
return res.json() return res.json()
@ -150,7 +150,7 @@ export default function SearchDefinitions() {
queryKey: ['invoice-ocr-data', editingDef?.source_invoice_id], queryKey: ['invoice-ocr-data', editingDef?.source_invoice_id],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/ocr-data`, { const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/ocr-data`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch OCR data') if (!res.ok) throw new Error('Failed to fetch OCR data')
return res.json() return res.json()
@ -163,8 +163,8 @@ export default function SearchDefinitions() {
mutationFn: async (data: { id: number; updates: typeof editFormData }) => { mutationFn: async (data: { id: number; updates: typeof editFormData }) => {
const res = await fetch(`/kitchen/api/search/definitions/${data.id}`, { const res = await fetch(`/kitchen/api/search/definitions/${data.id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data.updates), body: JSON.stringify(data.updates),
@ -195,7 +195,7 @@ export default function SearchDefinitions() {
setInvoiceImageUrl(url) setInvoiceImageUrl(url)
// Check if PDF by fetching headers // Check if PDF by fetching headers
fetch(url, { headers: { Authorization: `Bearer ${token}` } }) fetch(url, { credentials: 'include' })
.then((res) => { .then((res) => {
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
setIsPDF(contentType.includes('pdf')) setIsPDF(contentType.includes('pdf'))

View file

@ -73,7 +73,7 @@ export default function SearchInvoices() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
@ -104,12 +104,12 @@ export default function SearchInvoices() {
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`/kitchen/api/search/invoices?${params}`, { const res = await fetch(`/kitchen/api/search/invoices?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Search failed') if (!res.ok) throw new Error('Search failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const toggleGroup = (name: string) => { const toggleGroup = (name: string) => {

View file

@ -145,7 +145,7 @@ export default function SearchLineItems() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
@ -157,7 +157,7 @@ export default function SearchLineItems() {
queryKey: ['search-settings'], queryKey: ['search-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/search/settings', { const res = await fetch('/kitchen/api/search/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch search settings') if (!res.ok) throw new Error('Failed to fetch search settings')
return res.json() return res.json()
@ -179,12 +179,12 @@ export default function SearchLineItems() {
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`/kitchen/api/search/line-items?${params}`, { const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Search failed') if (!res.ok) throw new Error('Search failed')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch alias suggestions for unmapped items without product codes // Fetch alias suggestions for unmapped items without product codes
@ -207,7 +207,7 @@ export default function SearchLineItems() {
const promises = Array.from(bySupplier.entries()).map(([sid, items]) => const promises = Array.from(bySupplier.entries()).map(([sid, items]) =>
fetch('/kitchen/api/ingredients/sources/alias-suggestions', { fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: sid, items }), body: JSON.stringify({ supplier_id: sid, items }),
}) })
.then(res => res.ok ? res.json() : []) .then(res => res.ok ? res.json() : [])
@ -225,8 +225,8 @@ export default function SearchLineItems() {
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => { mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, { const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ alias }), body: JSON.stringify({ alias }),
@ -301,7 +301,7 @@ export default function SearchLineItems() {
setIngredientSearchLoading(true) setIngredientSearchLoading(true)
try { try {
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, { const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) setIngredientSuggestions(await res.json()) if (res.ok) setIngredientSuggestions(await res.json())
} catch { /* ignore */ } } catch { /* ignore */ }
@ -373,7 +373,7 @@ export default function SearchLineItems() {
if (modalItem.most_recent_line_item_id && modalItem.most_recent_invoice_id) { if (modalItem.most_recent_line_item_id && modalItem.most_recent_invoice_id) {
await fetch(`/kitchen/api/invoices/${modalItem.most_recent_invoice_id}/line-items/${modalItem.most_recent_line_item_id}`, { await fetch(`/kitchen/api/invoices/${modalItem.most_recent_invoice_id}/line-items/${modalItem.most_recent_line_item_id}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
pack_quantity: costEdits.pack_quantity || null, pack_quantity: costEdits.pack_quantity || null,
unit_size: costEdits.unit_size || null, unit_size: costEdits.unit_size || null,
@ -401,7 +401,7 @@ export default function SearchLineItems() {
await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, { await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData), body: JSON.stringify(sourceData),
}) })
} }

View file

@ -32,7 +32,7 @@ export default function Suppliers() {
queryKey: ['suppliers'], queryKey: ['suppliers'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch suppliers') if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json() return res.json()
@ -43,8 +43,8 @@ export default function Suppliers() {
mutationFn: async ({ name, aliases, skip_dext, order_email, account_number }: { name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => { mutationFn: async ({ name, aliases, skip_dext, order_email, account_number }: { name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
const res = await fetch('/kitchen/api/suppliers/', { const res = await fetch('/kitchen/api/suppliers/', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }), body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
@ -68,8 +68,8 @@ export default function Suppliers() {
mutationFn: async ({ id, name, aliases, skip_dext, order_email, account_number }: { id: number; name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => { mutationFn: async ({ id, name, aliases, skip_dext, order_email, account_number }: { id: number; name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
const res = await fetch(`/kitchen/api/suppliers/${id}`, { const res = await fetch(`/kitchen/api/suppliers/${id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }), body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
@ -93,7 +93,7 @@ export default function Suppliers() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/suppliers/${id}`, { const res = await fetch(`/kitchen/api/suppliers/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete supplier') if (!res.ok) throw new Error('Failed to delete supplier')
return res.json() return res.json()

View file

@ -19,12 +19,12 @@ export default function SupportButton() {
queryKey: ['support-enabled'], queryKey: ['support-enabled'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/support/enabled', { const res = await fetch('/kitchen/api/support/enabled', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { enabled: false } if (!res.ok) return { enabled: false }
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}) })
@ -33,9 +33,9 @@ export default function SupportButton() {
mutationFn: async (data: { description: string; screenshot: string }) => { mutationFn: async (data: { description: string; screenshot: string }) => {
const res = await fetch('/kitchen/api/support/request', { const res = await fetch('/kitchen/api/support/request', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
description: data.description, description: data.description,

View file

@ -161,7 +161,7 @@ export default function Upload() {
const res = await fetch('/kitchen/api/invoices/upload', { const res = await fetch('/kitchen/api/invoices/upload', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
body: formData, body: formData,
}) })

View file

@ -1,413 +1,413 @@
import { useState } from 'react' import { useState } from 'react'
import { useAuth } from '../App' import { useAuth } from '../App'
// ============ Types ============ // ============ Types ============
interface UsageVarianceItem { interface UsageVarianceItem {
ingredient_id: number ingredient_id: number
ingredient_name: string ingredient_name: string
category: string | null category: string | null
standard_unit: string standard_unit: string
theoretical_qty: number theoretical_qty: number
theoretical_value: number theoretical_value: number
dishes_using: number dishes_using: number
actual_qty: number | null actual_qty: number | null
actual_value: number | null actual_value: number | null
invoice_count: number invoice_count: number
variance_qty: number | null variance_qty: number | null
variance_pct: number | null variance_pct: number | null
variance_value: number | null variance_value: number | null
} }
interface UnmappedSaleItem { interface UnmappedSaleItem {
menu_item_name: string menu_item_name: string
portion_name: string portion_name: string
total_qty: number total_qty: number
category: string | null category: string | null
} }
interface UsageVarianceResponse { interface UsageVarianceResponse {
from_date: string from_date: string
to_date: string to_date: string
items: UsageVarianceItem[] items: UsageVarianceItem[]
total_theoretical_value: number total_theoretical_value: number
total_actual_value: number total_actual_value: number
total_variance_value: number total_variance_value: number
mapped_dish_count: number mapped_dish_count: number
unmapped_dish_count: number unmapped_dish_count: number
ingredients_with_purchases: number ingredients_with_purchases: number
ingredients_without_purchases: number ingredients_without_purchases: number
unmapped_sales: UnmappedSaleItem[] unmapped_sales: UnmappedSaleItem[]
} }
// ============ Helpers ============ // ============ Helpers ============
function fmtQty(qty: number, unit: string): string { function fmtQty(qty: number, unit: string): string {
if (unit === 'g' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} kg` if (unit === 'g' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} kg`
if (unit === 'ml' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} ltr` if (unit === 'ml' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} ltr`
if (unit === 'g') return `${qty.toFixed(0)} g` if (unit === 'g') return `${qty.toFixed(0)} g`
if (unit === 'ml') return `${qty.toFixed(0)} ml` if (unit === 'ml') return `${qty.toFixed(0)} ml`
if (unit === 'kg') return `${qty.toFixed(2)} kg` if (unit === 'kg') return `${qty.toFixed(2)} kg`
if (unit === 'ltr') return `${qty.toFixed(2)} ltr` if (unit === 'ltr') return `${qty.toFixed(2)} ltr`
if (unit === 'each') return `${qty.toFixed(1)}` if (unit === 'each') return `${qty.toFixed(1)}`
return `${qty.toFixed(2)} ${unit}` return `${qty.toFixed(2)} ${unit}`
} }
function fmt(val: number | null | undefined): string { function fmt(val: number | null | undefined): string {
if (val == null) return '—' if (val == null) return '—'
return `£${Number(val).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` return `£${Number(val).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} }
function fmtPct(val: number | null | undefined): string { function fmtPct(val: number | null | undefined): string {
if (val == null) return '—' if (val == null) return '—'
const v = Number(val) const v = Number(val)
const sign = v > 0 ? '+' : '' const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%` return `${sign}${v.toFixed(1)}%`
} }
function varianceColor(pct: number | null, absVal: number | null): string { function varianceColor(pct: number | null, absVal: number | null): string {
if (pct == null && absVal == null) return '#666' if (pct == null && absVal == null) return '#666'
const absPct = Math.abs(pct ?? 0) const absPct = Math.abs(pct ?? 0)
const absValue = Math.abs(absVal ?? 0) const absValue = Math.abs(absVal ?? 0)
if (absPct > 50 || absValue > 50) return '#dc2626' // red if (absPct > 50 || absValue > 50) return '#dc2626' // red
if (absPct > 15 || absValue > 15) return '#d97706' // amber if (absPct > 15 || absValue > 15) return '#d97706' // amber
return '#16a34a' // green return '#16a34a' // green
} }
function varianceBg(pct: number | null, absVal: number | null): string { function varianceBg(pct: number | null, absVal: number | null): string {
if (pct == null && absVal == null) return 'transparent' if (pct == null && absVal == null) return 'transparent'
const absPct = Math.abs(pct ?? 0) const absPct = Math.abs(pct ?? 0)
const absValue = Math.abs(absVal ?? 0) const absValue = Math.abs(absVal ?? 0)
if (absPct > 50 || absValue > 50) return '#fef2f2' if (absPct > 50 || absValue > 50) return '#fef2f2'
if (absPct > 15 || absValue > 15) return '#fffbeb' if (absPct > 15 || absValue > 15) return '#fffbeb'
return 'transparent' return 'transparent'
} }
// ============ Component ============ // ============ Component ============
type SortKey = 'variance_value' | 'variance_pct' | 'name' type SortKey = 'variance_value' | 'variance_pct' | 'name'
export default function UsageVarianceReport() { export default function UsageVarianceReport() {
const { token } = useAuth() const { token } = useAuth()
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const [fromDate, setFromDate] = useState(monthAgo) const [fromDate, setFromDate] = useState(monthAgo)
const [toDate, setToDate] = useState(today) const [toDate, setToDate] = useState(today)
const [result, setResult] = useState<UsageVarianceResponse | null>(null) const [result, setResult] = useState<UsageVarianceResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [showAll, setShowAll] = useState(true) const [showAll, setShowAll] = useState(true)
const [sortKey, setSortKey] = useState<SortKey>('variance_value') const [sortKey, setSortKey] = useState<SortKey>('variance_value')
const [unmappedCollapsed, setUnmappedCollapsed] = useState(true) const [unmappedCollapsed, setUnmappedCollapsed] = useState(true)
const fetchReport = async () => { const fetchReport = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch( const res = await fetch(
`/kitchen/api/reports/usage-variance?from_date=${fromDate}&to_date=${toDate}`, `/kitchen/api/reports/usage-variance?from_date=${fromDate}&to_date=${toDate}`,
{ headers: { Authorization: `Bearer ${token}` } } { credentials: 'include' }
) )
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' })) const err = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(err.detail || 'Request failed') throw new Error(err.detail || 'Request failed')
} }
const data: UsageVarianceResponse = await res.json() const data: UsageVarianceResponse = await res.json()
setResult(data) setResult(data)
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const formatDate = (iso: string) => { const formatDate = (iso: string) => {
const d = new Date(iso + 'T00:00:00') const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
} }
// Filter + sort items // Filter + sort items
let items = result?.items ?? [] let items = result?.items ?? []
if (search) { if (search) {
const s = search.toLowerCase() const s = search.toLowerCase()
items = items.filter(i => i.ingredient_name.toLowerCase().includes(s) || (i.category || '').toLowerCase().includes(s)) items = items.filter(i => i.ingredient_name.toLowerCase().includes(s) || (i.category || '').toLowerCase().includes(s))
} }
if (!showAll) { if (!showAll) {
items = items.filter(i => Math.abs(i.variance_pct ?? 0) > 10 || Math.abs(i.variance_value ?? 0) > 5) items = items.filter(i => Math.abs(i.variance_pct ?? 0) > 10 || Math.abs(i.variance_value ?? 0) > 5)
} }
items = [...items].sort((a, b) => { items = [...items].sort((a, b) => {
if (sortKey === 'name') return a.ingredient_name.localeCompare(b.ingredient_name) if (sortKey === 'name') return a.ingredient_name.localeCompare(b.ingredient_name)
if (sortKey === 'variance_pct') return Math.abs(b.variance_pct ?? 0) - Math.abs(a.variance_pct ?? 0) if (sortKey === 'variance_pct') return Math.abs(b.variance_pct ?? 0) - Math.abs(a.variance_pct ?? 0)
return Math.abs(b.variance_value ?? 0) - Math.abs(a.variance_value ?? 0) return Math.abs(b.variance_value ?? 0) - Math.abs(a.variance_value ?? 0)
}) })
const r = result const r = result
return ( return (
<div style={styles.container}> <div style={styles.container}>
<h2 style={styles.pageTitle}>Theoretical vs Actual Usage</h2> <h2 style={styles.pageTitle}>Theoretical vs Actual Usage</h2>
{/* Date range selector */} {/* Date range selector */}
<div style={styles.dateBar}> <div style={styles.dateBar}>
<label style={styles.dateLabel}> <label style={styles.dateLabel}>
From From
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} style={styles.dateInput} /> <input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} style={styles.dateInput} />
</label> </label>
<label style={styles.dateLabel}> <label style={styles.dateLabel}>
To To
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} style={styles.dateInput} /> <input type="date" value={toDate} onChange={e => setToDate(e.target.value)} style={styles.dateInput} />
</label> </label>
<button onClick={fetchReport} disabled={loading} style={styles.generateBtn}> <button onClick={fetchReport} disabled={loading} style={styles.generateBtn}>
{loading ? 'Loading...' : 'Generate'} {loading ? 'Loading...' : 'Generate'}
</button> </button>
</div> </div>
{error && <div style={styles.error}>{error}</div>} {error && <div style={styles.error}>{error}</div>}
{r && ( {r && (
<> <>
{/* Summary banner */} {/* Summary banner */}
<div style={styles.summaryBar}> <div style={styles.summaryBar}>
<div style={styles.summaryPeriod}> <div style={styles.summaryPeriod}>
{formatDate(r.from_date)} {formatDate(r.to_date)} {formatDate(r.from_date)} {formatDate(r.to_date)}
</div> </div>
<div style={styles.summaryGrid}> <div style={styles.summaryGrid}>
<div style={styles.summaryItem}> <div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Theoretical Cost</div> <div style={styles.summaryLabel}>Theoretical Cost</div>
<div style={styles.summaryValue}>{fmt(r.total_theoretical_value)}</div> <div style={styles.summaryValue}>{fmt(r.total_theoretical_value)}</div>
</div> </div>
<div style={styles.summaryItem}> <div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Actual Purchased</div> <div style={styles.summaryLabel}>Actual Purchased</div>
<div style={styles.summaryValue}>{fmt(r.total_actual_value)}</div> <div style={styles.summaryValue}>{fmt(r.total_actual_value)}</div>
</div> </div>
<div style={styles.summaryItem}> <div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Variance</div> <div style={styles.summaryLabel}>Variance</div>
<div style={{ <div style={{
...styles.summaryValue, ...styles.summaryValue,
color: r.total_variance_value > 50 ? '#f87171' : r.total_variance_value < -50 ? '#fbbf24' : '#4ade80', color: r.total_variance_value > 50 ? '#f87171' : r.total_variance_value < -50 ? '#fbbf24' : '#4ade80',
}}> }}>
{fmt(r.total_variance_value)} {fmt(r.total_variance_value)}
</div> </div>
</div> </div>
</div> </div>
<div style={styles.summaryCounts}> <div style={styles.summaryCounts}>
<span style={styles.countBadgeGreen}>{r.mapped_dish_count} dishes mapped</span> <span style={styles.countBadgeGreen}>{r.mapped_dish_count} dishes mapped</span>
{r.unmapped_dish_count > 0 && ( {r.unmapped_dish_count > 0 && (
<span style={styles.countBadgeAmber}>{r.unmapped_dish_count} unmapped</span> <span style={styles.countBadgeAmber}>{r.unmapped_dish_count} unmapped</span>
)} )}
<span style={styles.countBadgeBlue}>{r.ingredients_with_purchases} ingredients with purchases</span> <span style={styles.countBadgeBlue}>{r.ingredients_with_purchases} ingredients with purchases</span>
{r.ingredients_without_purchases > 0 && ( {r.ingredients_without_purchases > 0 && (
<span style={styles.countBadgeBlue}>{r.ingredients_without_purchases} theoretical only</span> <span style={styles.countBadgeBlue}>{r.ingredients_without_purchases} theoretical only</span>
)} )}
</div> </div>
<div style={styles.footnote}> <div style={styles.footnote}>
Variance = Actual - Theoretical. Positive = over-purchased. This report estimates usage from recipes and may not account for stock carried forward, staff meals, or wastage. Variance = Actual - Theoretical. Positive = over-purchased. This report estimates usage from recipes and may not account for stock carried forward, staff meals, or wastage.
</div> </div>
</div> </div>
{/* Controls */} {/* Controls */}
<div style={styles.controlsBar}> <div style={styles.controlsBar}>
<input <input
type="text" type="text"
placeholder="Search ingredient..." placeholder="Search ingredient..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
style={styles.searchInput} style={styles.searchInput}
/> />
<label style={styles.toggleLabel}> <label style={styles.toggleLabel}>
<input <input
type="checkbox" type="checkbox"
checked={!showAll} checked={!showAll}
onChange={e => setShowAll(!e.target.checked)} onChange={e => setShowAll(!e.target.checked)}
/> />
{' '}Variances only {' '}Variances only
</label> </label>
<select <select
value={sortKey} value={sortKey}
onChange={e => setSortKey(e.target.value as SortKey)} onChange={e => setSortKey(e.target.value as SortKey)}
style={styles.sortSelect} style={styles.sortSelect}
> >
<option value="variance_value">Sort: Variance £</option> <option value="variance_value">Sort: Variance £</option>
<option value="variance_pct">Sort: Variance %</option> <option value="variance_pct">Sort: Variance %</option>
<option value="name">Sort: Name</option> <option value="name">Sort: Name</option>
</select> </select>
</div> </div>
{/* Main table */} {/* Main table */}
<div style={styles.tableWrap}> <div style={styles.tableWrap}>
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Ingredient</th> <th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th> <th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. Qty</th> <th style={{ ...styles.th, textAlign: 'right' }}>Theo. Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. £</th> <th style={{ ...styles.th, textAlign: 'right' }}>Theo. £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Actual Qty</th> <th style={{ ...styles.th, textAlign: 'right' }}>Actual Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Actual £</th> <th style={{ ...styles.th, textAlign: 'right' }}>Actual £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Variance £</th> <th style={{ ...styles.th, textAlign: 'right' }}>Variance £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Variance %</th> <th style={{ ...styles.th, textAlign: 'right' }}>Variance %</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Dishes</th> <th style={{ ...styles.th, textAlign: 'center' }}>Dishes</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Invoices</th> <th style={{ ...styles.th, textAlign: 'center' }}>Invoices</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map(item => { {items.map(item => {
const bg = varianceBg(item.variance_pct, item.variance_value) const bg = varianceBg(item.variance_pct, item.variance_value)
const vc = varianceColor(item.variance_pct, item.variance_value) const vc = varianceColor(item.variance_pct, item.variance_value)
return ( return (
<tr key={item.ingredient_id} style={{ ...styles.tr, background: bg }}> <tr key={item.ingredient_id} style={{ ...styles.tr, background: bg }}>
<td style={styles.td}> <td style={styles.td}>
<span style={{ fontWeight: 600 }}>{item.ingredient_name}</span> <span style={{ fontWeight: 600 }}>{item.ingredient_name}</span>
</td> </td>
<td style={{ ...styles.td, color: '#888', fontSize: '0.8rem' }}> <td style={{ ...styles.td, color: '#888', fontSize: '0.8rem' }}>
{item.category || '—'} {item.category || '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.theoretical_qty > 0 ? fmtQty(item.theoretical_qty, item.standard_unit) : '—'} {item.theoretical_qty > 0 ? fmtQty(item.theoretical_qty, item.standard_unit) : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.theoretical_value > 0 ? fmt(item.theoretical_value) : '—'} {item.theoretical_value > 0 ? fmt(item.theoretical_value) : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }} <td style={{ ...styles.td, textAlign: 'right' }}
title={item.actual_qty == null ? 'No mapped purchases in period' : undefined} title={item.actual_qty == null ? 'No mapped purchases in period' : undefined}
> >
{item.actual_qty != null ? fmtQty(item.actual_qty, item.standard_unit) : '—'} {item.actual_qty != null ? fmtQty(item.actual_qty, item.standard_unit) : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right' }}> <td style={{ ...styles.td, textAlign: 'right' }}>
{item.actual_value != null ? fmt(item.actual_value) : '—'} {item.actual_value != null ? fmt(item.actual_value) : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}> <td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
{item.variance_value != null ? fmt(item.variance_value) : '—'} {item.variance_value != null ? fmt(item.variance_value) : '—'}
</td> </td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}> <td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
{fmtPct(item.variance_pct)} {fmtPct(item.variance_pct)}
</td> </td>
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}> <td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
{item.dishes_using} {item.dishes_using}
</td> </td>
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}> <td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
{item.invoice_count || '—'} {item.invoice_count || '—'}
</td> </td>
</tr> </tr>
) )
})} })}
{items.length === 0 && ( {items.length === 0 && (
<tr><td colSpan={10} style={{ ...styles.td, textAlign: 'center', color: '#999' }}> <tr><td colSpan={10} style={{ ...styles.td, textAlign: 'center', color: '#999' }}>
{search || !showAll ? 'No items match filters.' : 'No data.'} {search || !showAll ? 'No items match filters.' : 'No data.'}
</td></tr> </td></tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Unmapped sales */} {/* Unmapped sales */}
{r.unmapped_sales.length > 0 && ( {r.unmapped_sales.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<div <div
style={styles.sectionHeader} style={styles.sectionHeader}
onClick={() => setUnmappedCollapsed(!unmappedCollapsed)} onClick={() => setUnmappedCollapsed(!unmappedCollapsed)}
> >
<span> <span>
<span style={styles.collapseIcon}>{unmappedCollapsed ? '▸' : '▾'}</span> <span style={styles.collapseIcon}>{unmappedCollapsed ? '▸' : '▾'}</span>
Unmapped Sales Items ({r.unmapped_sales.length}) no recipe, can't calculate theoretical usage Unmapped Sales Items ({r.unmapped_sales.length}) no recipe, can't calculate theoretical usage
</span> </span>
</div> </div>
{!unmappedCollapsed && ( {!unmappedCollapsed && (
<table style={styles.table}> <table style={styles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Menu Item</th> <th style={styles.th}>Menu Item</th>
<th style={styles.th}>Portion</th> <th style={styles.th}>Portion</th>
<th style={styles.th}>Category</th> <th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty Sold</th> <th style={{ ...styles.th, textAlign: 'right' }}>Qty Sold</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{r.unmapped_sales.map((u, idx) => ( {r.unmapped_sales.map((u, idx) => (
<tr key={idx} style={styles.tr}> <tr key={idx} style={styles.tr}>
<td style={styles.td}>{u.menu_item_name}</td> <td style={styles.td}>{u.menu_item_name}</td>
<td style={{ ...styles.td, color: '#888' }}> <td style={{ ...styles.td, color: '#888' }}>
{u.portion_name !== 'Normal' ? u.portion_name : '—'} {u.portion_name !== 'Normal' ? u.portion_name : '—'}
</td> </td>
<td style={{ ...styles.td, color: '#888' }}>{u.category || '—'}</td> <td style={{ ...styles.td, color: '#888' }}>{u.category || '—'}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{u.total_qty}</td> <td style={{ ...styles.td, textAlign: 'right' }}>{u.total_qty}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
) )
} }
// ============ Styles ============ // ============ Styles ============
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '1200px', margin: '0 auto', padding: '1.5rem' }, container: { maxWidth: '1200px', margin: '0 auto', padding: '1.5rem' },
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' }, pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
// Date bar // Date bar
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' as const }, dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
dateLabel: { fontSize: '0.8rem', fontWeight: 600, color: '#666', display: 'flex', flexDirection: 'column' as const, gap: '0.25rem' }, dateLabel: { fontSize: '0.8rem', fontWeight: 600, color: '#666', display: 'flex', flexDirection: 'column' as const, gap: '0.25rem' },
dateInput: { padding: '0.4rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem' }, dateInput: { padding: '0.4rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem' },
generateBtn: { generateBtn: {
padding: '0.45rem 1.2rem', background: '#e94560', color: 'white', border: 'none', padding: '0.45rem 1.2rem', background: '#e94560', color: 'white', border: 'none',
borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem', borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem',
}, },
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' }, error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
// Summary bar // Summary bar
summaryBar: { summaryBar: {
background: '#1a1a2e', color: 'white', padding: '1.25rem', borderRadius: '10px', marginBottom: '1.5rem', background: '#1a1a2e', color: 'white', padding: '1.25rem', borderRadius: '10px', marginBottom: '1.5rem',
}, },
summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' }, summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' },
summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const }, summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const },
summaryItem: {}, summaryItem: {},
summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 }, summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 },
summaryValue: { fontSize: '1.3rem', fontWeight: 700 }, summaryValue: { fontSize: '1.3rem', fontWeight: 700 },
summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' }, summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' },
countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' }, countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' }, countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' }, countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' },
footnote: { fontSize: '0.72rem', color: '#777', marginTop: '0.75rem', fontStyle: 'italic' as const, lineHeight: 1.5 }, footnote: { fontSize: '0.72rem', color: '#777', marginTop: '0.75rem', fontStyle: 'italic' as const, lineHeight: 1.5 },
// Controls // Controls
controlsBar: { controlsBar: {
display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const, display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const,
}, },
searchInput: { searchInput: {
padding: '0.4rem 0.6rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem', width: '200px', padding: '0.4rem 0.6rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem', width: '200px',
}, },
toggleLabel: { fontSize: '0.85rem', color: '#555', cursor: 'pointer', userSelect: 'none' as const }, toggleLabel: { fontSize: '0.85rem', color: '#555', cursor: 'pointer', userSelect: 'none' as const },
sortSelect: { sortSelect: {
padding: '0.35rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.8rem', color: '#555', padding: '0.35rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.8rem', color: '#555',
}, },
// Table // Table
tableWrap: { overflowX: 'auto' as const }, tableWrap: { overflowX: 'auto' as const },
table: { width: '100%', borderCollapse: 'collapse' as const }, table: { width: '100%', borderCollapse: 'collapse' as const },
th: { th: {
padding: '0.5rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', padding: '0.5rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0',
fontSize: '0.72rem', fontWeight: 600, color: '#666', background: '#fafafa', fontSize: '0.72rem', fontWeight: 600, color: '#666', background: '#fafafa',
whiteSpace: 'nowrap' as const, whiteSpace: 'nowrap' as const,
}, },
tr: { borderBottom: '1px solid #f0f0f0' }, tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.4rem 0.75rem', fontSize: '0.83rem', whiteSpace: 'nowrap' as const }, td: { padding: '0.4rem 0.75rem', fontSize: '0.83rem', whiteSpace: 'nowrap' as const },
// Unmapped section // Unmapped section
section: { section: {
marginTop: '1.5rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden', marginTop: '1.5rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden',
}, },
sectionHeader: { sectionHeader: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer',
fontWeight: 600, fontSize: '0.9rem', color: '#888', fontWeight: 600, fontSize: '0.9rem', color: '#888',
}, },
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' }, collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
} }

View file

@ -152,7 +152,7 @@ export default function BookingsStats() {
queryKey: ['resos-stats', submittedFromDate, submittedToDate], queryKey: ['resos-stats', submittedFromDate, submittedToDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/resos/stats?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { const res = await fetch(`/kitchen/api/resos/stats?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch stats') if (!res.ok) throw new Error('Failed to fetch stats')
return res.json() return res.json()
@ -183,7 +183,7 @@ export default function BookingsStats() {
queryFn: async () => { queryFn: async () => {
const prevDates = getPreviousPeriodDates() const prevDates = getPreviousPeriodDates()
const res = await fetch(`/kitchen/api/resos/stats?from_date=${prevDates.from}&to_date=${prevDates.to}`, { const res = await fetch(`/kitchen/api/resos/stats?from_date=${prevDates.from}&to_date=${prevDates.to}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch previous stats') if (!res.ok) throw new Error('Failed to fetch previous stats')
return res.json() return res.json()
@ -196,7 +196,7 @@ export default function BookingsStats() {
queryKey: ['resos-bookings', selectedDate], queryKey: ['resos-bookings', selectedDate],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, { const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch bookings') if (!res.ok) throw new Error('Failed to fetch bookings')
return res.json() return res.json()

View file

@ -52,12 +52,12 @@ export default function NewbookData() {
queryKey: ['settings'], queryKey: ['settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch settings') if (!res.ok) throw new Error('Failed to fetch settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const currencySymbol = settings?.currency_symbol || '£' const currencySymbol = settings?.currency_symbol || '£'
@ -66,12 +66,12 @@ export default function NewbookData() {
queryKey: ['newbook-calendar', year, month], queryKey: ['newbook-calendar', year, month],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/newbook/calendar/${year}/${month}`, { const res = await fetch(`/kitchen/api/newbook/calendar/${year}/${month}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch calendar data') if (!res.ok) throw new Error('Failed to fetch calendar data')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const goToPrevMonth = () => { const goToPrevMonth = () => {

View file

@ -67,7 +67,7 @@ export default function ResidentsTableChart() {
queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/residents-table-chart?start_date=${startDate}`, { const res = await fetch(`/kitchen/api/residents-table-chart?start_date=${startDate}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch chart data') if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json() return res.json()

View file

@ -116,12 +116,12 @@ export default function ResosData() {
queryKey: ['resos-settings'], queryKey: ['resos-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/resos/settings', { const res = await fetch('/kitchen/api/resos/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch settings') if (!res.ok) throw new Error('Failed to fetch settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch daily stats for the month // Fetch daily stats for the month
@ -133,12 +133,12 @@ export default function ResosData() {
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}` const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, { const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch daily stats') if (!res.ok) throw new Error('Failed to fetch daily stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch previous month's stats for comparison // Fetch previous month's stats for comparison
@ -152,12 +152,12 @@ export default function ResosData() {
const toDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}` const toDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, { const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch previous month stats') if (!res.ok) throw new Error('Failed to fetch previous month stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch calendar events for the month // Fetch calendar events for the month
@ -168,7 +168,7 @@ export default function ResosData() {
const lastDay = new Date(year, month, 0) const lastDay = new Date(year, month, 0)
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}` const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
const res = await fetch(`/kitchen/api/calendar-events/?from_date=${firstDay}&to_date=${toDate}`, { const res = await fetch(`/kitchen/api/calendar-events/?from_date=${firstDay}&to_date=${toDate}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch calendar events') if (!res.ok) throw new Error('Failed to fetch calendar events')
return res.json() return res.json()
@ -182,7 +182,7 @@ export default function ResosData() {
queryFn: async () => { queryFn: async () => {
if (!selectedDate) return [] if (!selectedDate) return []
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, { const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch bookings') if (!res.ok) throw new Error('Failed to fetch bookings')
return res.json() return res.json()
@ -195,7 +195,7 @@ export default function ResosData() {
queryKey: ['resos-all-opening-hours'], queryKey: ['resos-all-opening-hours'],
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/kitchen/api/resos/opening-hours`, { const res = await fetch(`/kitchen/api/resos/opening-hours`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch opening hours') if (!res.ok) throw new Error('Failed to fetch opening hours')
return res.json() return res.json()
@ -231,7 +231,7 @@ export default function ResosData() {
queryFn: async () => { queryFn: async () => {
if (!selectedDate) return [] if (!selectedDate) return []
const res = await fetch(`/kitchen/api/resos/opening-hours/${selectedDate}`, { const res = await fetch(`/kitchen/api/resos/opening-hours/${selectedDate}`, {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!res.ok) throw new Error('Failed to fetch opening hours') if (!res.ok) throw new Error('Failed to fetch opening hours')
return res.json() return res.json()
@ -1324,7 +1324,7 @@ export default function ResosData() {
if (confirm('Delete this event?')) { if (confirm('Delete this event?')) {
await fetch(`/kitchen/api/calendar-events/${editingEvent.id}`, { await fetch(`/kitchen/api/calendar-events/${editingEvent.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
setShowEventModal(false) setShowEventModal(false)
refetchEvents() refetchEvents()
@ -1365,8 +1365,8 @@ export default function ResosData() {
await fetch(url, { await fetch(url, {
method, method,
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(eventForm) body: JSON.stringify(eventForm)

View file

@ -536,7 +536,7 @@ export default function Settings() {
queryKey: ['settings'], queryKey: ['settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch settings') if (!res.ok) throw new Error('Failed to fetch settings')
return res.json() return res.json()
@ -548,7 +548,7 @@ export default function Settings() {
queryKey: ['newbook-settings'], queryKey: ['newbook-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/newbook/settings', { const res = await fetch('/kitchen/api/newbook/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const errorText = await res.text() const errorText = await res.text()
@ -567,7 +567,7 @@ export default function Settings() {
queryKey: ['resos-settings'], queryKey: ['resos-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/resos/settings', { const res = await fetch('/kitchen/api/resos/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const errorText = await res.text() const errorText = await res.text()
@ -585,7 +585,7 @@ export default function Settings() {
queryKey: ['gl-accounts'], queryKey: ['gl-accounts'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/newbook/gl-accounts', { const res = await fetch('/kitchen/api/newbook/gl-accounts', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -597,7 +597,7 @@ export default function Settings() {
queryKey: ['room-categories'], queryKey: ['room-categories'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/newbook/room-categories', { const res = await fetch('/kitchen/api/newbook/room-categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -610,12 +610,12 @@ export default function Settings() {
queryKey: ['sambapos-settings'], queryKey: ['sambapos-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/sambapos/settings', { const res = await fetch('/kitchen/api/sambapos/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch SambaPOS settings') if (!res.ok) throw new Error('Failed to fetch SambaPOS settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch SambaPOS categories (only when connection is configured) // Fetch SambaPOS categories (only when connection is configured)
@ -623,7 +623,7 @@ export default function Settings() {
queryKey: ['sambapos-categories'], queryKey: ['sambapos-categories'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/sambapos/categories', { const res = await fetch('/kitchen/api/sambapos/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -640,7 +640,7 @@ export default function Settings() {
queryKey: ['sambapos-group-codes'], queryKey: ['sambapos-group-codes'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/sambapos/group-codes', { const res = await fetch('/kitchen/api/sambapos/group-codes', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -657,7 +657,7 @@ export default function Settings() {
queryKey: ['sambapos-gl-codes'], queryKey: ['sambapos-gl-codes'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/sambapos/gl-codes', { const res = await fetch('/kitchen/api/sambapos/gl-codes', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -674,12 +674,12 @@ export default function Settings() {
queryKey: ['sambapos-selected-gl-codes'], queryKey: ['sambapos-selected-gl-codes'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/sambapos/gl-codes/selected', { const res = await fetch('/kitchen/api/sambapos/gl-codes/selected', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch selected GL codes') if (!res.ok) throw new Error('Failed to fetch selected GL codes')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch KDS settings // Fetch KDS settings
@ -687,12 +687,12 @@ export default function Settings() {
queryKey: ['kds-settings'], queryKey: ['kds-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/kds/settings', { const res = await fetch('/kitchen/api/kds/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch KDS settings') if (!res.ok) throw new Error('Failed to fetch KDS settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Kitchen Details // Fetch Kitchen Details
@ -710,12 +710,12 @@ export default function Settings() {
queryKey: ['kitchen-details'], queryKey: ['kitchen-details'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/kitchen-details', { const res = await fetch('/kitchen/api/settings/kitchen-details', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch kitchen details') if (!res.ok) throw new Error('Failed to fetch kitchen details')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Budget settings // Fetch Budget settings
@ -730,12 +730,12 @@ export default function Settings() {
queryKey: ['budget-settings'], queryKey: ['budget-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/budget/settings', { const res = await fetch('/kitchen/api/budget/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch Budget settings') if (!res.ok) throw new Error('Failed to fetch Budget settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Nextcloud settings // Fetch Nextcloud settings
@ -743,12 +743,12 @@ export default function Settings() {
queryKey: ['nextcloud-settings'], queryKey: ['nextcloud-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/nextcloud', { const res = await fetch('/kitchen/api/settings/nextcloud', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch Nextcloud settings') if (!res.ok) throw new Error('Failed to fetch Nextcloud settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch which integrations are configured in the central stack settings service // Fetch which integrations are configured in the central stack settings service
@ -756,12 +756,12 @@ export default function Settings() {
queryKey: ['global-integration-status'], queryKey: ['global-integration-status'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/global-status', { const res = await fetch('/kitchen/api/settings/global-status', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return {} if (!res.ok) return {}
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
staleTime: 60000, staleTime: 60000,
}) })
@ -770,12 +770,12 @@ export default function Settings() {
queryKey: ['nextcloud-stats'], queryKey: ['nextcloud-stats'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/nextcloud/stats', { const res = await fetch('/kitchen/api/settings/nextcloud/stats', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch Nextcloud stats') if (!res.ok) throw new Error('Failed to fetch Nextcloud stats')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Backup settings // Fetch Backup settings
@ -783,12 +783,12 @@ export default function Settings() {
queryKey: ['backup-settings'], queryKey: ['backup-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/backup/settings', { const res = await fetch('/kitchen/api/backup/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch backup settings') if (!res.ok) throw new Error('Failed to fetch backup settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Backup history // Fetch Backup history
@ -796,12 +796,12 @@ export default function Settings() {
queryKey: ['backup-history'], queryKey: ['backup-history'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/backup/history', { const res = await fetch('/kitchen/api/backup/history', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch backup history') if (!res.ok) throw new Error('Failed to fetch backup history')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch Search settings // Fetch Search settings
@ -809,12 +809,12 @@ export default function Settings() {
queryKey: ['search-settings'], queryKey: ['search-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/search/settings', { const res = await fetch('/kitchen/api/search/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch search settings') if (!res.ok) throw new Error('Failed to fetch search settings')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch IMAP settings // Fetch IMAP settings
@ -822,7 +822,7 @@ export default function Settings() {
queryKey: ['imap-settings'], queryKey: ['imap-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/imap/settings', { const res = await fetch('/kitchen/api/imap/settings', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch IMAP settings') if (!res.ok) throw new Error('Failed to fetch IMAP settings')
return res.json() return res.json()
@ -835,7 +835,7 @@ export default function Settings() {
queryKey: ['imap-logs'], queryKey: ['imap-logs'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/imap/logs?limit=20', { const res = await fetch('/kitchen/api/imap/logs?limit=20', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch IMAP logs') if (!res.ok) throw new Error('Failed to fetch IMAP logs')
return res.json() return res.json()
@ -848,7 +848,7 @@ export default function Settings() {
queryKey: ['imap-stats'], queryKey: ['imap-stats'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/imap/logs/stats', { const res = await fetch('/kitchen/api/imap/logs/stats', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch IMAP stats') if (!res.ok) throw new Error('Failed to fetch IMAP stats')
return res.json() return res.json()
@ -1053,7 +1053,7 @@ export default function Settings() {
// Auto-fetch custom fields if mapping exists but fields list is empty // Auto-fetch custom fields if mapping exists but fields list is empty
if (resosSettings.resos_custom_field_mapping && Object.keys(resosSettings.resos_custom_field_mapping).length > 0 && customFields.length === 0) { if (resosSettings.resos_custom_field_mapping && Object.keys(resosSettings.resos_custom_field_mapping).length > 0 && customFields.length === 0) {
fetch('/kitchen/api/resos/custom-fields', { fetch('/kitchen/api/resos/custom-fields', {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setCustomFields(data.custom_fields || [])) .then(data => setCustomFields(data.custom_fields || []))
@ -1063,7 +1063,7 @@ export default function Settings() {
// Auto-fetch opening hours if mapping exists but hours list is empty // Auto-fetch opening hours if mapping exists but hours list is empty
if (resosSettings.resos_opening_hours_mapping && resosSettings.resos_opening_hours_mapping.length > 0 && openingHours.length === 0) { if (resosSettings.resos_opening_hours_mapping && resosSettings.resos_opening_hours_mapping.length > 0 && openingHours.length === 0) {
fetch('/kitchen/api/resos/opening-hours', { fetch('/kitchen/api/resos/opening-hours', {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => setOpeningHours(data.opening_hours || [])) .then(data => setOpeningHours(data.opening_hours || []))
@ -1077,7 +1077,7 @@ export default function Settings() {
queryKey: ['food-flag-categories'], queryKey: ['food-flag-categories'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/food-flags/categories', { const res = await fetch('/kitchen/api/food-flags/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -1089,7 +1089,7 @@ export default function Settings() {
queryKey: ['api-access-settings'], queryKey: ['api-access-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/api-access', { const res = await fetch('/kitchen/api/settings/api-access', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { api_key: null, api_key_enabled: false } if (!res.ok) return { api_key: null, api_key_enabled: false }
return res.json() return res.json()
@ -1109,7 +1109,7 @@ export default function Settings() {
mutationFn: async (data: { name: string; propagation_type: string }) => { mutationFn: async (data: { name: string; propagation_type: string }) => {
const res = await fetch('/kitchen/api/food-flags/categories', { const res = await fetch('/kitchen/api/food-flags/categories', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to create category') if (!res.ok) throw new Error('Failed to create category')
@ -1130,7 +1130,7 @@ export default function Settings() {
mutationFn: async ({ id, data }: { id: number; data: { name?: string; propagation_type?: string; required?: boolean } }) => { mutationFn: async ({ id, data }: { id: number; data: { name?: string; propagation_type?: string; required?: boolean } }) => {
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, { const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to update category') if (!res.ok) throw new Error('Failed to update category')
@ -1149,7 +1149,7 @@ export default function Settings() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, { const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete category') if (!res.ok) throw new Error('Failed to delete category')
return res.json() return res.json()
@ -1166,7 +1166,7 @@ export default function Settings() {
mutationFn: async (data: { category_id: number; name: string; code?: string; icon?: string }) => { mutationFn: async (data: { category_id: number; name: string; code?: string; icon?: string }) => {
const res = await fetch('/kitchen/api/food-flags/flags', { const res = await fetch('/kitchen/api/food-flags/flags', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to create flag') if (!res.ok) throw new Error('Failed to create flag')
@ -1188,7 +1188,7 @@ export default function Settings() {
mutationFn: async ({ id, data }: { id: number; data: { name?: string; code?: string; icon?: string } }) => { mutationFn: async ({ id, data }: { id: number; data: { name?: string; code?: string; icon?: string } }) => {
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, { const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to update flag') if (!res.ok) throw new Error('Failed to update flag')
@ -1207,7 +1207,7 @@ export default function Settings() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, { const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete flag') if (!res.ok) throw new Error('Failed to delete flag')
return res.json() return res.json()
@ -1238,7 +1238,7 @@ export default function Settings() {
queryKey: ['allergen-keywords'], queryKey: ['allergen-keywords'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/food-flags/keywords', { const res = await fetch('/kitchen/api/food-flags/keywords', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -1250,7 +1250,7 @@ export default function Settings() {
mutationFn: async ({ food_flag_id, keyword }: { food_flag_id: number; keyword: string }) => { mutationFn: async ({ food_flag_id, keyword }: { food_flag_id: number; keyword: string }) => {
const res = await fetch('/kitchen/api/food-flags/keywords', { const res = await fetch('/kitchen/api/food-flags/keywords', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_id, keyword }), body: JSON.stringify({ food_flag_id, keyword }),
}) })
if (!res.ok) { if (!res.ok) {
@ -1275,7 +1275,7 @@ export default function Settings() {
mutationFn: async (keywordId: number) => { mutationFn: async (keywordId: number) => {
const res = await fetch(`/kitchen/api/food-flags/keywords/${keywordId}`, { const res = await fetch(`/kitchen/api/food-flags/keywords/${keywordId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete keyword') if (!res.ok) throw new Error('Failed to delete keyword')
}, },
@ -1288,7 +1288,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/food-flags/keywords/reset-defaults', { const res = await fetch('/kitchen/api/food-flags/keywords/reset-defaults', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to reset keywords') if (!res.ok) throw new Error('Failed to reset keywords')
}, },
@ -1311,7 +1311,7 @@ export default function Settings() {
queryKey: ['ingredient-categories-settings'], queryKey: ['ingredient-categories-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/categories', { const res = await fetch('/kitchen/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -1323,7 +1323,7 @@ export default function Settings() {
mutationFn: async (data: { name: string; sort_order: number }) => { mutationFn: async (data: { name: string; sort_order: number }) => {
const res = await fetch('/kitchen/api/ingredients/categories', { const res = await fetch('/kitchen/api/ingredients/categories', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to create category') } if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to create category') }
@ -1344,7 +1344,7 @@ export default function Settings() {
mutationFn: async ({ id, data }: { id: number; data: { name?: string; sort_order?: number } }) => { mutationFn: async ({ id, data }: { id: number; data: { name?: string; sort_order?: number } }) => {
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, { const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to update category') } if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to update category') }
@ -1363,7 +1363,7 @@ export default function Settings() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, { const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete category') if (!res.ok) throw new Error('Failed to delete category')
return res.json() return res.json()
@ -1393,7 +1393,7 @@ export default function Settings() {
queryKey: ['recipe-sections-settings'], queryKey: ['recipe-sections-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=recipe', { const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=recipe', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -1406,7 +1406,7 @@ export default function Settings() {
queryKey: ['dish-courses-settings'], queryKey: ['dish-courses-settings'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=dish', { const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=dish', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
@ -1419,7 +1419,7 @@ export default function Settings() {
mutationFn: async (data: { api_key_enabled: boolean }) => { mutationFn: async (data: { api_key_enabled: boolean }) => {
const res = await fetch('/kitchen/api/settings/api-access', { const res = await fetch('/kitchen/api/settings/api-access', {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to save API access settings') if (!res.ok) throw new Error('Failed to save API access settings')
@ -1438,7 +1438,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/settings/api-access/regenerate', { const res = await fetch('/kitchen/api/settings/api-access/regenerate', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to regenerate API key') if (!res.ok) throw new Error('Failed to regenerate API key')
return res.json() return res.json()
@ -1462,7 +1462,7 @@ export default function Settings() {
queryKey: ['llm-usage'], queryKey: ['llm-usage'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/llm-usage', { const res = await fetch('/kitchen/api/settings/llm-usage', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { total_calls: 0, successful_calls: 0, failed_calls: 0, total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0, cache_entries_this_month: 0 } if (!res.ok) return { total_calls: 0, successful_calls: 0, failed_calls: 0, total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0, cache_entries_this_month: 0 }
return res.json() return res.json()
@ -1480,7 +1480,7 @@ export default function Settings() {
queryKey: ['llm-models'], queryKey: ['llm-models'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/kitchen/api/settings/llm-models', { const res = await fetch('/kitchen/api/settings/llm-models', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) return { models: [], default: 'claude-haiku-4-5-20251001', current: 'claude-haiku-4-5-20251001' } if (!res.ok) return { models: [], default: 'claude-haiku-4-5-20251001', current: 'claude-haiku-4-5-20251001' }
return res.json() return res.json()
@ -1493,7 +1493,7 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!res.ok) throw new Error('Failed to save LLM settings') if (!res.ok) throw new Error('Failed to save LLM settings')
@ -1512,7 +1512,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/settings/test-llm', { const res = await fetch('/kitchen/api/settings/test-llm', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1548,8 +1548,8 @@ export default function Settings() {
mutationFn: async (data: Partial<SettingsData & { azure_key?: string }>) => { mutationFn: async (data: Partial<SettingsData & { azure_key?: string }>) => {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1572,7 +1572,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/settings/test-azure', { const res = await fetch('/kitchen/api/settings/test-azure', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1593,8 +1593,8 @@ export default function Settings() {
mutationFn: async (data: { current_password: string; new_password: string }) => { mutationFn: async (data: { current_password: string; new_password: string }) => {
const res = await fetch('/auth/change-password', { const res = await fetch('/auth/change-password', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1621,7 +1621,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/invoices/reprocess-all', { const res = await fetch('/kitchen/api/invoices/reprocess-all', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1643,7 +1643,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/recipes/cleanup-false-price-changes', { const res = await fetch('/kitchen/api/recipes/cleanup-false-price-changes', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1664,7 +1664,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/recipes/backfill-invoice-references', { const res = await fetch('/kitchen/api/recipes/backfill-invoice-references', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1685,7 +1685,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/suppliers/rematch-fuzzy', { const res = await fetch('/kitchen/api/suppliers/rematch-fuzzy', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1709,8 +1709,8 @@ export default function Settings() {
console.log('[Newbook] Sending PATCH with data:', data) console.log('[Newbook] Sending PATCH with data:', data)
const res = await fetch('/kitchen/api/newbook/settings', { const res = await fetch('/kitchen/api/newbook/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1741,7 +1741,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/newbook/test-connection', { const res = await fetch('/kitchen/api/newbook/test-connection', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1764,8 +1764,8 @@ export default function Settings() {
console.log('[Resos] Sending PATCH with data:', data) console.log('[Resos] Sending PATCH with data:', data)
const res = await fetch('/kitchen/api/resos/settings', { const res = await fetch('/kitchen/api/resos/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1794,7 +1794,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/newbook/gl-accounts/fetch', { const res = await fetch('/kitchen/api/newbook/gl-accounts/fetch', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1816,7 +1816,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/newbook/sync/forecast', { const res = await fetch('/kitchen/api/newbook/sync/forecast', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1838,8 +1838,8 @@ export default function Settings() {
mutationFn: async (dates: { date_from: string; date_to: string }) => { mutationFn: async (dates: { date_from: string; date_to: string }) => {
const res = await fetch('/kitchen/api/newbook/sync/historical', { const res = await fetch('/kitchen/api/newbook/sync/historical', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(dates), body: JSON.stringify(dates),
@ -1865,8 +1865,8 @@ export default function Settings() {
mutationFn: async ({ id, is_tracked }: { id: number; is_tracked: boolean }) => { mutationFn: async ({ id, is_tracked }: { id: number; is_tracked: boolean }) => {
const res = await fetch(`/kitchen/api/newbook/gl-accounts/${id}`, { const res = await fetch(`/kitchen/api/newbook/gl-accounts/${id}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ is_tracked }), body: JSON.stringify({ is_tracked }),
@ -1883,8 +1883,8 @@ export default function Settings() {
mutationFn: async (updates: { id: number; is_tracked: boolean }[]) => { mutationFn: async (updates: { id: number; is_tracked: boolean }[]) => {
const res = await fetch('/kitchen/api/newbook/gl-accounts/bulk-update', { const res = await fetch('/kitchen/api/newbook/gl-accounts/bulk-update', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ updates }), body: JSON.stringify({ updates }),
@ -1902,7 +1902,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/newbook/room-categories/fetch', { const res = await fetch('/kitchen/api/newbook/room-categories/fetch', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1924,8 +1924,8 @@ export default function Settings() {
mutationFn: async (updates: { id: number; is_included: boolean }[]) => { mutationFn: async (updates: { id: number; is_included: boolean }[]) => {
const res = await fetch('/kitchen/api/newbook/room-categories/bulk-update', { const res = await fetch('/kitchen/api/newbook/room-categories/bulk-update', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ updates }), body: JSON.stringify({ updates }),
@ -1943,8 +1943,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/sambapos/settings', { const res = await fetch('/kitchen/api/sambapos/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -1967,7 +1967,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/sambapos/test-connection', { const res = await fetch('/kitchen/api/sambapos/test-connection', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
@ -1989,8 +1989,8 @@ export default function Settings() {
mutationFn: async (courses: string[]) => { mutationFn: async (courses: string[]) => {
const res = await fetch('/kitchen/api/sambapos/tracked-categories', { const res = await fetch('/kitchen/api/sambapos/tracked-categories', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ categories: courses }), body: JSON.stringify({ categories: courses }),
@ -2012,8 +2012,8 @@ export default function Settings() {
mutationFn: async (items: string[]) => { mutationFn: async (items: string[]) => {
const res = await fetch('/kitchen/api/sambapos/excluded-items', { const res = await fetch('/kitchen/api/sambapos/excluded-items', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ items }), body: JSON.stringify({ items }),
@ -2035,8 +2035,8 @@ export default function Settings() {
mutationFn: async (data: { food_codes: string[]; beverage_codes: string[] }) => { mutationFn: async (data: { food_codes: string[]; beverage_codes: string[] }) => {
const res = await fetch('/kitchen/api/sambapos/gl-codes', { const res = await fetch('/kitchen/api/sambapos/gl-codes', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2059,8 +2059,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/kds/settings', { const res = await fetch('/kitchen/api/kds/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2082,7 +2082,7 @@ export default function Settings() {
const kdsTestMutation = useMutation({ const kdsTestMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/kds/test-connection', { const res = await fetch('/kitchen/api/kds/test-connection', {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (!data.success) { if (!data.success) {
@ -2104,8 +2104,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/settings/kitchen-details', { const res = await fetch('/kitchen/api/settings/kitchen-details', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2130,8 +2130,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/budget/settings', { const res = await fetch('/kitchen/api/budget/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2157,7 +2157,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/budget/test-forecast-connection', { const res = await fetch('/kitchen/api/budget/test-forecast-connection', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (!data.success) { if (!data.success) {
@ -2179,8 +2179,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/settings/nextcloud', { const res = await fetch('/kitchen/api/settings/nextcloud', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2206,7 +2206,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/settings/nextcloud/test', { const res = await fetch('/kitchen/api/settings/nextcloud/test', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const error = await res.json() const error = await res.json()
@ -2227,7 +2227,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/settings/nextcloud/archive-all', { const res = await fetch('/kitchen/api/settings/nextcloud/archive-all', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const error = await res.json() const error = await res.json()
@ -2250,8 +2250,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/backup/settings', { const res = await fetch('/kitchen/api/backup/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -2276,7 +2276,7 @@ export default function Settings() {
mutationFn: async () => { mutationFn: async () => {
const res = await fetch('/kitchen/api/backup/create', { const res = await fetch('/kitchen/api/backup/create', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const error = await res.json() const error = await res.json()
@ -2299,7 +2299,7 @@ export default function Settings() {
mutationFn: async (backupId: number) => { mutationFn: async (backupId: number) => {
const res = await fetch(`/kitchen/api/backup/${backupId}/restore`, { const res = await fetch(`/kitchen/api/backup/${backupId}/restore`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const error = await res.json() const error = await res.json()
@ -2322,7 +2322,7 @@ export default function Settings() {
mutationFn: async (backupId: number) => { mutationFn: async (backupId: number) => {
const res = await fetch(`/kitchen/api/backup/${backupId}`, { const res = await fetch(`/kitchen/api/backup/${backupId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) { if (!res.ok) {
const error = await res.json() const error = await res.json()
@ -2346,7 +2346,7 @@ export default function Settings() {
formData.append('file', file) formData.append('file', file)
const res = await fetch('/kitchen/api/backup/upload', { const res = await fetch('/kitchen/api/backup/upload', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
body: formData, body: formData,
}) })
if (!res.ok) { if (!res.ok) {
@ -2370,8 +2370,8 @@ export default function Settings() {
mutationFn: async (data: Record<string, unknown>) => { mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/kitchen/api/search/settings', { const res = await fetch('/kitchen/api/search/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -3135,9 +3135,9 @@ export default function Settings() {
const saveRes = await fetch('/kitchen/api/settings/', { const saveRes = await fetch('/kitchen/api/settings/', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify(savePayload) body: JSON.stringify(savePayload)
}) })
@ -3153,7 +3153,7 @@ export default function Settings() {
setSmtpTestStatus('Testing connection...') setSmtpTestStatus('Testing connection...')
const res = await fetch('/kitchen/api/settings/test-smtp', { const res = await fetch('/kitchen/api/settings/test-smtp', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -3178,9 +3178,9 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
smtp_host: smtpHost || null, smtp_host: smtpHost || null,
@ -3355,9 +3355,9 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/imap/test-connection', { const res = await fetch('/kitchen/api/imap/test-connection', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
imap_host: imapHost || undefined, imap_host: imapHost || undefined,
@ -3388,7 +3388,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/imap/sync-now', { const res = await fetch('/kitchen/api/imap/sync-now', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
@ -3418,9 +3418,9 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/imap/settings', { const res = await fetch('/kitchen/api/imap/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
imap_host: imapHost || null, imap_host: imapHost || null,
@ -3624,9 +3624,9 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/settings/', { const res = await fetch('/kitchen/api/settings/', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
dext_email: dextEmail || null, dext_email: dextEmail || null,
@ -3675,7 +3675,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/invoices/bulk/mark-all-dext-sent', { const res = await fetch('/kitchen/api/invoices/bulk/mark-all-dext-sent', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const result = await res.json() const result = await res.json()
@ -4073,7 +4073,7 @@ export default function Settings() {
setResosTestStatus('Testing...') setResosTestStatus('Testing...')
const res = await fetch('/kitchen/api/resos/test-connection', { const res = await fetch('/kitchen/api/resos/test-connection', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
setResosTestStatus('✓ Connection successful') setResosTestStatus('✓ Connection successful')
@ -4142,7 +4142,7 @@ export default function Settings() {
onClick={async () => { onClick={async () => {
try { try {
const res = await fetch('/kitchen/api/resos/custom-fields', { const res = await fetch('/kitchen/api/resos/custom-fields', {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -4207,7 +4207,7 @@ export default function Settings() {
// First, sync opening hours to database (POST endpoint) // First, sync opening hours to database (POST endpoint)
const syncRes = await fetch('/kitchen/api/resos/sync/opening-hours', { const syncRes = await fetch('/kitchen/api/resos/sync/opening-hours', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (!syncRes.ok) { if (!syncRes.ok) {
@ -4220,7 +4220,7 @@ export default function Settings() {
// Then, fetch opening hours for display (GET endpoint) // Then, fetch opening hours for display (GET endpoint)
const res = await fetch('/kitchen/api/resos/opening-hours', { const res = await fetch('/kitchen/api/resos/opening-hours', {
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -4531,7 +4531,7 @@ export default function Settings() {
setResosSaveMessage('Syncing upcoming bookings...') setResosSaveMessage('Syncing upcoming bookings...')
const res = await fetch('/kitchen/api/resos/sync/upcoming', { const res = await fetch('/kitchen/api/resos/sync/upcoming', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -4559,7 +4559,7 @@ export default function Settings() {
setResosSaveMessage('Syncing forecast...') setResosSaveMessage('Syncing forecast...')
const res = await fetch('/kitchen/api/resos/sync/forecast', { const res = await fetch('/kitchen/api/resos/sync/forecast', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -4599,8 +4599,8 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/resos/settings', { const res = await fetch('/kitchen/api/resos/settings', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -4682,7 +4682,7 @@ export default function Settings() {
setResosSaveMessage('Syncing historical data...') setResosSaveMessage('Syncing historical data...')
const res = await fetch(`/kitchen/api/resos/sync/historical?from_date=${historicalResosDateFrom}&to_date=${historicalResosDateTo}`, { const res = await fetch(`/kitchen/api/resos/sync/historical?from_date=${historicalResosDateFrom}&to_date=${historicalResosDateTo}`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } credentials: 'include'
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -6188,7 +6188,7 @@ export default function Settings() {
<div style={styles.actionButtons}> <div style={styles.actionButtons}>
<button <button
onClick={() => { onClick={() => {
window.open(`/kitchen/api/backup/${backup.id}/download?token=${token}`, '_blank') window.open(`/kitchen/api/backup/${backup.id}/download`, '_blank')
}} }}
style={styles.actionBtn} style={styles.actionBtn}
disabled={backup.status !== 'success'} disabled={backup.status !== 'success'}
@ -6249,7 +6249,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/food-flags/seed-defaults', { const res = await fetch('/kitchen/api/food-flags/seed-defaults', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
@ -6676,7 +6676,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/ingredients/categories/seed-defaults', { const res = await fetch('/kitchen/api/ingredients/categories/seed-defaults', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
@ -6846,7 +6846,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=recipe', { const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=recipe', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
@ -6891,7 +6891,7 @@ export default function Settings() {
<button <button
onClick={async () => { onClick={async () => {
if (confirm(`Delete "${sec.name}"?${sec.recipe_count > 0 ? ` ${sec.recipe_count} recipe(s) will become unsectioned.` : ''}`)) { if (confirm(`Delete "${sec.name}"?${sec.recipe_count > 0 ? ` ${sec.recipe_count} recipe(s) will become unsectioned.` : ''}`)) {
const res = await fetch(`/kitchen/api/recipes/menu-sections/${sec.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }) const res = await fetch(`/kitchen/api/recipes/menu-sections/${sec.id}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) refetchRecipeSections() if (res.ok) refetchRecipeSections()
} }
}} }}
@ -6921,7 +6921,7 @@ export default function Settings() {
if (!name) return if (!name) return
const res = await fetch('/kitchen/api/recipes/menu-sections', { const res = await fetch('/kitchen/api/recipes/menu-sections', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }), body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
}) })
if (res.ok) { input.value = ''; refetchRecipeSections() } if (res.ok) { input.value = ''; refetchRecipeSections() }
@ -6935,7 +6935,7 @@ export default function Settings() {
if (!name) return if (!name) return
const res = await fetch('/kitchen/api/recipes/menu-sections', { const res = await fetch('/kitchen/api/recipes/menu-sections', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }), body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
}) })
if (res.ok) { input.value = ''; refetchRecipeSections() } if (res.ok) { input.value = ''; refetchRecipeSections() }
@ -6957,7 +6957,7 @@ export default function Settings() {
try { try {
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=dish', { const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=dish', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
@ -7002,7 +7002,7 @@ export default function Settings() {
<button <button
onClick={async () => { onClick={async () => {
if (confirm(`Delete "${course.name}"?${course.recipe_count > 0 ? ` ${course.recipe_count} dish(es) will become uncategorised.` : ''}`)) { if (confirm(`Delete "${course.name}"?${course.recipe_count > 0 ? ` ${course.recipe_count} dish(es) will become uncategorised.` : ''}`)) {
const res = await fetch(`/kitchen/api/recipes/menu-sections/${course.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }) const res = await fetch(`/kitchen/api/recipes/menu-sections/${course.id}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) refetchDishCourses() if (res.ok) refetchDishCourses()
} }
}} }}
@ -7032,7 +7032,7 @@ export default function Settings() {
if (!name) return if (!name) return
const res = await fetch('/kitchen/api/recipes/menu-sections', { const res = await fetch('/kitchen/api/recipes/menu-sections', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }), body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
}) })
if (res.ok) { input.value = ''; refetchDishCourses() } if (res.ok) { input.value = ''; refetchDishCourses() }
@ -7046,7 +7046,7 @@ export default function Settings() {
if (!name) return if (!name) return
const res = await fetch('/kitchen/api/recipes/menu-sections', { const res = await fetch('/kitchen/api/recipes/menu-sections', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }), body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
}) })
if (res.ok) { input.value = ''; refetchDishCourses() } if (res.ok) { input.value = ''; refetchDishCourses() }

File diff suppressed because it is too large Load diff

View file

@ -121,12 +121,12 @@ export default function WastageLogbook() {
queryFn: async () => { queryFn: async () => {
const url = queryString ? `/kitchen/api/logbook?${queryString}` : '/kitchen/api/logbook' const url = queryString ? `/kitchen/api/logbook?${queryString}` : '/kitchen/api/logbook'
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch logbook entries') if (!res.ok) throw new Error('Failed to fetch logbook entries')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
// Fetch summary // Fetch summary
@ -140,19 +140,19 @@ export default function WastageLogbook() {
queryFn: async () => { queryFn: async () => {
const url = summaryString ? `/kitchen/api/logbook/summary?${summaryString}` : '/kitchen/api/logbook/summary' const url = summaryString ? `/kitchen/api/logbook/summary?${summaryString}` : '/kitchen/api/logbook/summary'
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to fetch summary') if (!res.ok) throw new Error('Failed to fetch summary')
return res.json() return res.json()
}, },
enabled: !!token, enabled: true,
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (entryId: number) => { mutationFn: async (entryId: number) => {
const res = await fetch(`/kitchen/api/logbook/${entryId}`, { const res = await fetch(`/kitchen/api/logbook/${entryId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (!res.ok) throw new Error('Failed to delete entry') if (!res.ok) throw new Error('Failed to delete entry')
return res.json() return res.json()
@ -571,7 +571,7 @@ function CreateEntryModal({
} }
try { try {
const res = await fetch(`/kitchen/api/logbook/products/search?query=${encodeURIComponent(query)}`, { const res = await fetch(`/kitchen/api/logbook/products/search?query=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${token}` }, credentials: 'include',
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@ -662,9 +662,9 @@ function CreateEntryModal({
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })