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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ from decimal import Decimal
from typing import Optional
from html import escape as html_escape
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, delete
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DATA_DIR = "/app/data"
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
# ── Pydantic schemas ─────────────────────────────────────────────────────────
@ -686,16 +686,33 @@ async def list_recipes(
result = await db.execute(query.order_by(Recipe.name))
recipes = result.scalars().all()
# Batch-load latest cost snapshot per recipe (1 query vs N)
recipe_ids = [r.id for r in recipes]
snap_map: dict[int, RecipeCostSnapshot] = {}
if recipe_ids:
latest_subq = (
select(
RecipeCostSnapshot.recipe_id,
func.max(RecipeCostSnapshot.snapshot_date).label("max_date"),
)
.where(RecipeCostSnapshot.recipe_id.in_(recipe_ids))
.group_by(RecipeCostSnapshot.recipe_id)
.subquery()
)
snap_rows = await db.execute(
select(RecipeCostSnapshot).join(
latest_subq,
and_(
RecipeCostSnapshot.recipe_id == latest_subq.c.recipe_id,
RecipeCostSnapshot.snapshot_date == latest_subq.c.max_date,
),
)
)
snap_map = {s.recipe_id: s for s in snap_rows.scalars().all()}
items = []
for r in recipes:
# Get latest cost snapshot
snap_result = await db.execute(
select(RecipeCostSnapshot)
.where(RecipeCostSnapshot.recipe_id == r.id)
.order_by(RecipeCostSnapshot.snapshot_date.desc())
.limit(1)
)
snap = snap_result.scalar_one_or_none()
snap = snap_map.get(r.id)
# Get flag summary (lightweight)
from api.food_flags import compute_recipe_flags
@ -1529,13 +1546,18 @@ async def upload_image(
async def serve_image(
recipe_id: int,
image_id: int,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
# img tags can't send Authorization header, so auth via query param
if not token:
raise HTTPException(401, "Not authenticated")
user = await get_current_user_from_token(token, db)
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
if not user:
raise HTTPException(401, "Not authenticated")
await _get_recipe(recipe_id, user.kitchen_id, db)
@ -1728,6 +1750,8 @@ async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional
needed_unit = sr.portions_needed_unit or child_output_unit
portions_needed_raw = float(sr.portions_needed) * scale_factor
portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit)
if not child_output_qty:
logger.warning(f"Recipe {child.id} ({child.name!r}) has zero yield qty — cost contribution zeroed in parent recipe")
scale_ratio = portions_needed / child_output_qty if child_output_qty else 0
cost_contribution = float(child_total) * scale_ratio if child_total else None
cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None
@ -2098,14 +2122,19 @@ async def backfill_invoice_references(
@router.get("/{recipe_id}/print")
async def print_recipe(
recipe_id: int,
request: Request,
format: str = Query("full"), # "full" | "kitchen"
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
# window.open() can't send Authorization header, so auth via query param
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user = await get_current_user_from_token(token, db)
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
@ -2116,11 +2145,11 @@ async def print_recipe(
from api.food_flags import compute_recipe_flags
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token)
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id)
return HTMLResponse(content=html)
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str:
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0) -> str:
"""Generate print-optimised HTML for a recipe."""
esc = html_escape
name = esc(recipe_data.get("name", ""))
@ -2204,7 +2233,7 @@ def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str =
if not plating_images:
plating_images = recipe_data.get("images", [])[:1]
for img in plating_images:
img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}"
img_url = f"/kitchen/api/recipes/{recipe_id}/images/{img['id']}"
images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
time_info = ""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 }>({
queryKey: ['settings'],
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 {}
return res.json()
},
enabled: !!token,
enabled: true,
staleTime: 60000,
})
@ -137,21 +137,21 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
queryKey: ['dispute', disputeId],
queryFn: async () => {
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch dispute')
return res.json()
},
enabled: !!token,
enabled: true,
})
const updateMutation = useMutation({
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}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
})
@ -174,8 +174,8 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
mutationFn: async () => {
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) {
@ -253,7 +253,7 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
try {
const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
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}`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
})

View file

@ -60,12 +60,12 @@ export default function Disputes() {
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
enabled: !!token,
enabled: true,
})
// Build query params
@ -85,12 +85,12 @@ export default function Disputes() {
queryFn: async () => {
const url = queryString ? `/kitchen/api/disputes?${queryString}` : '/kitchen/api/disputes'
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch disputes')
return res.json()
},
enabled: !!token,
enabled: true,
})
// 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom'
interface EventOrderItem {
id: number
name: string
event_date: string | null
notes: string | null
status: string
item_count: number
estimated_cost: number | null
created_at: string
updated_at: string
}
export default function EventOrders() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [formName, setFormName] = useState('')
const [formDate, setFormDate] = useState('')
const [formNotes, setFormNotes] = useState('')
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
queryKey: ['event-orders'],
queryFn: async () => {
const res = await fetch('/kitchen/api/event-orders', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token,
})
const createMutation = useMutation({
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
const res = await fetch('/kitchen/api/event-orders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create')
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['event-orders'] })
setShowCreate(false)
navigate(`/event-orders/${data.id}`)
},
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/event-orders/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
})
const statusColors: Record<string, string> = {
DRAFT: '#f59e0b',
FINALISED: '#3b82f6',
ORDERED: '#22c55e',
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Event Orders</h2>
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
+ New Event Order
</button>
</div>
{isLoading ? (
<div style={styles.loading}>Loading...</div>
) : orders && orders.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Date</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Recipes</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<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}>{o.event_date || '-'}</td>
<td style={styles.td}>
<span style={{
background: statusColors[o.status] || '#ccc',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{o.status}
</span>
</td>
<td style={styles.td}>{o.item_count}</td>
<td style={styles.td}>
{o.status === 'DRAFT' && (
<button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
style={{ ...styles.smallBtn, color: '#e94560' }}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
No event orders yet. Create one to start planning.
</div>
)}
{showCreate && (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>New Event Order</h3>
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}></button>
</div>
<div style={styles.modalBody}>
<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" />
<label style={styles.label}>Event Date</label>
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
<label style={styles.label}>Notes</label>
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
</div>
<div style={styles.modalFooter}>
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
<button
onClick={() => createMutation.mutate({
name: formName,
event_date: formDate || undefined,
notes: formNotes || undefined,
})}
disabled={!formName || createMutation.isPending}
style={styles.primaryBtn}
>
Create
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
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)' },
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' },
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' },
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' },
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 },
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' },
modalBody: { padding: '1.25rem' },
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' },
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 },
}
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom'
interface EventOrderItem {
id: number
name: string
event_date: string | null
notes: string | null
status: string
item_count: number
estimated_cost: number | null
created_at: string
updated_at: string
}
export default function EventOrders() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [formName, setFormName] = useState('')
const [formDate, setFormDate] = useState('')
const [formNotes, setFormNotes] = useState('')
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
queryKey: ['event-orders'],
queryFn: async () => {
const res = await fetch('/kitchen/api/event-orders', {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: true,
})
const createMutation = useMutation({
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
const res = await fetch('/kitchen/api/event-orders', {
method: 'POST',
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create')
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['event-orders'] })
setShowCreate(false)
navigate(`/event-orders/${data.id}`)
},
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/event-orders/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
})
const statusColors: Record<string, string> = {
DRAFT: '#f59e0b',
FINALISED: '#3b82f6',
ORDERED: '#22c55e',
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Event Orders</h2>
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
+ New Event Order
</button>
</div>
{isLoading ? (
<div style={styles.loading}>Loading...</div>
) : orders && orders.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Date</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Recipes</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<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}>{o.event_date || '-'}</td>
<td style={styles.td}>
<span style={{
background: statusColors[o.status] || '#ccc',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{o.status}
</span>
</td>
<td style={styles.td}>{o.item_count}</td>
<td style={styles.td}>
{o.status === 'DRAFT' && (
<button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
style={{ ...styles.smallBtn, color: '#e94560' }}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
No event orders yet. Create one to start planning.
</div>
)}
{showCreate && (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>New Event Order</h3>
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}></button>
</div>
<div style={styles.modalBody}>
<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" />
<label style={styles.label}>Event Date</label>
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
<label style={styles.label}>Notes</label>
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
</div>
<div style={styles.modalFooter}>
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
<button
onClick={() => createMutation.mutate({
name: formName,
event_date: formDate || undefined,
notes: formNotes || undefined,
})}
disabled={!formName || createMutation.isPending}
style={styles.primaryBtn}
>
Create
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
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)' },
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' },
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' },
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' },
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 },
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' },
modalBody: { padding: '1.25rem' },
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' },
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 },
}

View file

@ -158,14 +158,22 @@ export default function GPReport() {
const monthOptions = getMonthOptions()
// Allowances checkbox state - default: all checked EXCEPT wastage
const [allowancesSelection, setAllowancesSelection] = useState({
wastage: false, // Wastage: unchecked by default
transfer: true, // Transfer: checked by default
staffFood: true, // Staff Food: checked by default
manualAdjustment: true, // Manual Adjustment: checked by default
disputes: true, // Open Disputes: checked by default
cdDeductions: true, // Distributed Deductions: checked by default
cdReallocations: true // Distributed Reallocations: checked by default
const _defaultAllowances = {
wastage: false,
transfer: true,
staffFood: true,
manualAdjustment: true,
disputes: true,
cdDeductions: true,
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
@ -318,7 +326,7 @@ export default function GPReport() {
queryKey: ['gp-range', submittedFromDate, submittedToDate],
queryFn: async () => {
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')
return res.json()
@ -332,7 +340,7 @@ export default function GPReport() {
queryKey: ['gp-daily', submittedFromDate, submittedToDate],
queryFn: async () => {
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')
return res.json()
@ -346,7 +354,7 @@ export default function GPReport() {
queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate],
queryFn: async () => {
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) {
// 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
const gpWithSelectedAllowances = salesNum > 0
? ((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100)
? Math.min((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100, 100)
: 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
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 (
@ -626,7 +684,14 @@ export default function GPReport() {
</div>
{/* 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 */}
<div style={styles.mainContent}>
@ -1252,9 +1317,18 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#1a1a2e',
marginBottom: '1rem',
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: {
display: 'flex',
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import FoodFlagBadges from './FoodFlagBadges'
import IngredientModal from './IngredientModal'
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
interface FlagInfo {
id: number
food_flag_id: number
flag_name: string
flag_code: string | null
category_name: string
propagation_type: string
source: string
}
interface IngredientItem {
id: number
name: string
category_id: number | null
category_name: string | null
standard_unit: string
yield_percent: number
manual_price: number | null
notes: string | null
is_archived: boolean
is_prepackaged: boolean
is_free: boolean
product_ingredients: string | null
has_label_image: boolean
source_count: number
effective_price: number | null
flags: FlagInfo[]
none_categories: string[]
created_at: string
}
interface SourceItem {
id: number
supplier_id: number
supplier_name: string
product_code: string | null
description_pattern: string | null
pack_quantity: number | null
unit_size: number | null
unit_size_type: string | null
latest_unit_price: number | null
latest_invoice_date: string | null
price_per_std_unit: number | null
}
export default function Ingredients() {
const { token } = useAuth()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnmapped, setShowUnmapped] = useState(false)
const [showArchived, setShowArchived] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
// Categories (for filter dropdown)
const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'],
queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch categories')
return res.json()
},
enabled: !!token,
})
// Ingredients list
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
queryFn: async () => {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (categoryFilter) params.set('category_id', categoryFilter)
if (showUnmapped) params.set('unmapped', 'true')
if (showArchived) params.set('archived', 'true')
const res = await fetch(`/kitchen/api/ingredients?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json()
},
enabled: !!token,
})
// Sources for expanded ingredient
const { data: sources } = useQuery<SourceItem[]>({
queryKey: ['ingredient-sources', expandedId],
queryFn: async () => {
const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch sources')
return res.json()
},
enabled: !!token && !!expandedId,
})
const archiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to archive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const unarchiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }),
})
if (!res.ok) throw new Error('Failed to unarchive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const startEdit = (ing: IngredientItem) => {
setEditingIngredient({
id: ing.id,
name: ing.name,
category_id: ing.category_id,
standard_unit: ing.standard_unit,
yield_percent: ing.yield_percent,
manual_price: ing.manual_price,
notes: ing.notes,
is_prepackaged: ing.is_prepackaged,
is_free: ing.is_free,
product_ingredients: ing.product_ingredients,
has_label_image: ing.has_label_image,
})
setShowModal(true)
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Ingredient Library</h2>
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
+ Create Ingredient
</button>
</div>
{/* Filters */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search ingredients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option>
{categories?.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
))}
</select>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showUnmapped}
onChange={(e) => setShowUnmapped(e.target.checked)}
/>
Unmapped only
</label>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
/>
Show Archived
</label>
</div>
{/* Stats */}
<div style={styles.statsBar}>
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
<span style={{ color: '#888' }}>|</span>
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
</div>
{/* Table */}
{isLoading ? (
<div style={styles.loading}>Loading ingredients...</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Unit</th>
<th style={styles.th}>Yield %</th>
<th style={styles.th}>Sources</th>
<th style={styles.th}>Price/Unit</th>
<th style={styles.th}>Flags</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{ingredients?.map(ing => (
<>
<tr
key={ing.id}
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
>
<td style={styles.td}>
<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>}
</td>
<td style={styles.td}>{ing.category_name || '-'}</td>
<td style={styles.td}>{ing.standard_unit}</td>
<td style={styles.td}>
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
{ing.yield_percent}%
</span>
</td>
<td style={styles.td}>
<span style={{
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
color: 'white',
padding: '2px 8px',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{ing.source_count}
</span>
</td>
<td style={styles.td}>
{ing.effective_price != null
? (() => {
const p = ing.effective_price!
const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})()
: <span style={{ color: '#aaa' }}>-</span>
}
</td>
<td style={styles.td}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
<FoodFlagBadges flags={ing.flags.map(f => ({
name: f.flag_name,
code: f.flag_code || undefined,
category_name: f.category_name,
propagation: f.propagation_type,
}))} />
{ing.none_categories?.map(cat => (
<span
key={cat}
title={`${cat}: None apply`}
style={{
display: 'inline-block',
borderRadius: '10px',
color: 'white',
fontWeight: 600,
whiteSpace: 'nowrap',
lineHeight: 1.4,
fontSize: '0.7rem',
padding: '1px 5px',
background: '#999',
}}
>
{cat.substring(0, 3)}: None
</span>
))}
</div>
</td>
<td style={styles.td}>
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
{ing.is_archived ? (
<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>
)}
</td>
</tr>
{expandedId === ing.id && (
<tr key={`${ing.id}-expanded`}>
<td colSpan={8} style={styles.expandedTd}>
<div style={styles.sourcesSection}>
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
{sources && sources.length > 0 ? (
<table style={{ ...styles.table, margin: 0 }}>
<thead>
<tr>
<th style={styles.thSmall}>Supplier</th>
<th style={styles.thSmall}>Code/Pattern</th>
<th style={styles.thSmall}>Pack</th>
<th style={styles.thSmall}>Last Price</th>
<th style={styles.thSmall}>Price/{ing.standard_unit}</th>
<th style={styles.thSmall}>Last Invoice</th>
</tr>
</thead>
<tbody>
{sources.map(s => (
<tr key={s.id}>
<td style={styles.tdSmall}>{s.supplier_name}</td>
<td style={styles.tdSmall}>
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
<a
href={`https://www.brake.co.uk/p/${s.product_code}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'underline' }}
title="View on Brakes website"
>
{s.product_code}
</a>
) : (
s.product_code || s.description_pattern || '-'
)}
</td>
<td style={styles.tdSmall}>
{s.pack_quantity && s.unit_size
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
: '-'
}
</td>
<td style={styles.tdSmall}>
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
</td>
<td style={styles.tdSmall}>
{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 u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})() : '-'}
</td>
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
<IngredientModal
open={showModal}
onClose={() => { setShowModal(false); setEditingIngredient(null) }}
editingIngredient={editingIngredient}
/>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
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' },
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' },
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)' },
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' },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
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' },
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' },
}
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import FoodFlagBadges from './FoodFlagBadges'
import IngredientModal from './IngredientModal'
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
interface FlagInfo {
id: number
food_flag_id: number
flag_name: string
flag_code: string | null
category_name: string
propagation_type: string
source: string
}
interface IngredientItem {
id: number
name: string
category_id: number | null
category_name: string | null
standard_unit: string
yield_percent: number
manual_price: number | null
notes: string | null
is_archived: boolean
is_prepackaged: boolean
is_free: boolean
product_ingredients: string | null
has_label_image: boolean
source_count: number
effective_price: number | null
flags: FlagInfo[]
none_categories: string[]
created_at: string
}
interface SourceItem {
id: number
supplier_id: number
supplier_name: string
product_code: string | null
description_pattern: string | null
pack_quantity: number | null
unit_size: number | null
unit_size_type: string | null
latest_unit_price: number | null
latest_invoice_date: string | null
price_per_std_unit: number | null
}
export default function Ingredients() {
const { token } = useAuth()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnmapped, setShowUnmapped] = useState(false)
const [showArchived, setShowArchived] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
// Categories (for filter dropdown)
const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'],
queryFn: async () => {
const res = await fetch('/kitchen/api/ingredients/categories', {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch categories')
return res.json()
},
enabled: true,
})
// Ingredients list
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
queryFn: async () => {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (categoryFilter) params.set('category_id', categoryFilter)
if (showUnmapped) params.set('unmapped', 'true')
if (showArchived) params.set('archived', 'true')
const res = await fetch(`/kitchen/api/ingredients?${params}`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json()
},
enabled: true,
})
// Sources for expanded ingredient
const { data: sources } = useQuery<SourceItem[]>({
queryKey: ['ingredient-sources', expandedId],
queryFn: async () => {
const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch sources')
return res.json()
},
enabled: !!token && !!expandedId,
})
const archiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to archive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const unarchiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
method: 'PATCH',
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }),
})
if (!res.ok) throw new Error('Failed to unarchive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const startEdit = (ing: IngredientItem) => {
setEditingIngredient({
id: ing.id,
name: ing.name,
category_id: ing.category_id,
standard_unit: ing.standard_unit,
yield_percent: ing.yield_percent,
manual_price: ing.manual_price,
notes: ing.notes,
is_prepackaged: ing.is_prepackaged,
is_free: ing.is_free,
product_ingredients: ing.product_ingredients,
has_label_image: ing.has_label_image,
})
setShowModal(true)
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Ingredient Library</h2>
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
+ Create Ingredient
</button>
</div>
{/* Filters */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search ingredients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option>
{categories?.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
))}
</select>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showUnmapped}
onChange={(e) => setShowUnmapped(e.target.checked)}
/>
Unmapped only
</label>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
/>
Show Archived
</label>
</div>
{/* Stats */}
<div style={styles.statsBar}>
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
<span style={{ color: '#888' }}>|</span>
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
</div>
{/* Table */}
{isLoading ? (
<div style={styles.loading}>Loading ingredients...</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Unit</th>
<th style={styles.th}>Yield %</th>
<th style={styles.th}>Sources</th>
<th style={styles.th}>Price/Unit</th>
<th style={styles.th}>Flags</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{ingredients?.map(ing => (
<>
<tr
key={ing.id}
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
>
<td style={styles.td}>
<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>}
</td>
<td style={styles.td}>{ing.category_name || '-'}</td>
<td style={styles.td}>{ing.standard_unit}</td>
<td style={styles.td}>
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
{ing.yield_percent}%
</span>
</td>
<td style={styles.td}>
<span style={{
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
color: 'white',
padding: '2px 8px',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{ing.source_count}
</span>
</td>
<td style={styles.td}>
{ing.effective_price != null
? (() => {
const p = ing.effective_price!
const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})()
: <span style={{ color: '#aaa' }}>-</span>
}
</td>
<td style={styles.td}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
<FoodFlagBadges flags={ing.flags.map(f => ({
name: f.flag_name,
code: f.flag_code || undefined,
category_name: f.category_name,
propagation: f.propagation_type,
}))} />
{ing.none_categories?.map(cat => (
<span
key={cat}
title={`${cat}: None apply`}
style={{
display: 'inline-block',
borderRadius: '10px',
color: 'white',
fontWeight: 600,
whiteSpace: 'nowrap',
lineHeight: 1.4,
fontSize: '0.7rem',
padding: '1px 5px',
background: '#999',
}}
>
{cat.substring(0, 3)}: None
</span>
))}
</div>
</td>
<td style={styles.td}>
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
{ing.is_archived ? (
<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>
)}
</td>
</tr>
{expandedId === ing.id && (
<tr key={`${ing.id}-expanded`}>
<td colSpan={8} style={styles.expandedTd}>
<div style={styles.sourcesSection}>
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
{sources && sources.length > 0 ? (
<table style={{ ...styles.table, margin: 0 }}>
<thead>
<tr>
<th style={styles.thSmall}>Supplier</th>
<th style={styles.thSmall}>Code/Pattern</th>
<th style={styles.thSmall}>Pack</th>
<th style={styles.thSmall}>Last Price</th>
<th style={styles.thSmall}>Price/{ing.standard_unit}</th>
<th style={styles.thSmall}>Last Invoice</th>
</tr>
</thead>
<tbody>
{sources.map(s => (
<tr key={s.id}>
<td style={styles.tdSmall}>{s.supplier_name}</td>
<td style={styles.tdSmall}>
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
<a
href={`https://www.brake.co.uk/p/${s.product_code}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'underline' }}
title="View on Brakes website"
>
{s.product_code}
</a>
) : (
s.product_code || s.description_pattern || '-'
)}
</td>
<td style={styles.tdSmall}>
{s.pack_quantity && s.unit_size
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
: '-'
}
</td>
<td style={styles.tdSmall}>
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
</td>
<td style={styles.tdSmall}>
{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 u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})() : '-'}
</td>
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
<IngredientModal
open={showModal}
onClose={() => { setShowModal(false); setEditingIngredient(null) }}
editingIngredient={editingIngredient}
/>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
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' },
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' },
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)' },
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' },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
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' },
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' },
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,333 +1,333 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface MatrixCell {
has_flag: boolean
is_unassessed: boolean
is_none: boolean
has_open_suggestion?: boolean
}
interface MatrixIngredient {
ingredient_id: number
ingredient_name: string
is_sub_recipe: boolean
sub_recipe_name: string | null
flags: Record<number, MatrixCell>
}
interface FlagColumn {
id: number
name: string
code: string | null
category_id: number
category_name: string
propagation_type: string
required: boolean
}
interface MatrixData {
flags: FlagColumn[]
ingredients: MatrixIngredient[]
}
interface Props {
recipeId: number
categoryId?: number
}
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
const { token } = useAuth()
const queryClient = useQueryClient()
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
const { data: rawData, isLoading } = useQuery<MatrixData>({
queryKey: ['recipe-flag-matrix', recipeId],
queryFn: async () => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch matrix')
return res.json()
},
enabled: !!token && !!recipeId,
})
// Filter to specific category if provided
const data = rawData ? {
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
ingredients: rawData.ingredients,
} : undefined
const toggleMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
})
if (!res.ok) throw new Error('Failed to update flag')
},
onMutate: ({ ingredientId, flagId }) => {
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
},
onSettled: (_data, _err, { ingredientId, flagId }) => {
setPendingCells(prev => {
const next = new Set(prev)
next.delete(`${ingredientId}-${flagId}`)
return next
})
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
})
if (!res.ok) throw new Error('Failed to toggle none')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
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>
// Group flags by category
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
for (const f of data.flags) {
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].flags.push(f)
}
// Group ingredients by sub-recipe
let currentSubRecipe = ''
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
for (const ing of data.ingredients) {
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
currentSubRecipe = ing.sub_recipe_name || ''
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
} else if (!ing.is_sub_recipe && currentSubRecipe) {
currentSubRecipe = ''
}
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
}
// Compute totals per flag
const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
for (const f of data.flags) {
const cat = categories[f.category_id]
if (cat.propagation === 'contains') {
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
totals[f.id] = { has: anyHas, unassessed: false }
} else {
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
}
}
// Check if "none" is set per ingredient per category
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
const catFlags = categories[catId]?.flags || []
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
}
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
}
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
const hasFlag = cell?.has_flag ?? false
const isUnassessed = cell?.is_unassessed ?? false
const isNone = cell?.is_none ?? false
const hasOpenSuggestion = cell?.has_open_suggestion ?? false
const cellStyle: React.CSSProperties = {
...styles.cell,
cursor: 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'background 0.15s',
position: 'relative' as const,
}
// Small amber dot for open suggestions (not already flagged or unassessed)
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
<span style={{
position: 'absolute', top: '2px', right: '2px',
width: '6px', height: '6px', borderRadius: '50%',
background: '#f59e0b',
}} title="Unreviewed allergen suggestion" />
) : null
const handleClick = () => {
if (isPending) return
if (isUnassessed) {
handleToggle(ingredientId, flagId, false)
} else {
handleToggle(ingredientId, flagId, hasFlag)
}
}
if (isUnassessed) {
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
}
if (isNone) {
return (
<td
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
onClick={handleClick}
>
{'\u2014'}{suggestionDot}
</td>
)
}
if (propagation === 'contains') {
return (
<td
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 || ''}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
</td>
)
} else {
return (
<td
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
</td>
)
}
}
const showNoneColumn = Object.values(categories).some(c => c.required)
return (
<div style={styles.container}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
</span>
</div>
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
<table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
<tr>
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => (
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
{Object.keys(categories).length > 1 && (
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
)}
<div>{f.code || f.name.substring(0, 4)}</div>
</th>
))}
{cat.required && (
<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>
</th>
)}
</>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
if (row.type === 'header') {
const noneColCount = Object.values(categories).filter(c => c.required).length
const totalCols = data.flags.length + noneColCount + 1
return (
<tr key={`h-${idx}`}>
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
</tr>
)
}
const ing = row.ingredient!
return (
<tr key={ing.ingredient_id + '-' + idx}>
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
{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.required && (
<td
key={`none-${catId}-${ing.ingredient_id}`}
style={{
...styles.cell,
borderLeft: '2px solid #e0e0e0',
cursor: 'pointer',
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
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}`}
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
>
N
</td>
)}
</>
))}
</tr>
)
})}
{/* Total row */}
<tr style={{ borderTop: '3px double #333' }}>
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => {
const t = totals[f.id]
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
if (cat.propagation === 'contains') {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
{t.has ? '\u2713' : ''}
</td>
} else {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
{t.has ? '\u2713' : '\u2717'}
</td>
}
})}
{cat.required && (
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
)}
</>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
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' },
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 },
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' },
subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' },
}
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface MatrixCell {
has_flag: boolean
is_unassessed: boolean
is_none: boolean
has_open_suggestion?: boolean
}
interface MatrixIngredient {
ingredient_id: number
ingredient_name: string
is_sub_recipe: boolean
sub_recipe_name: string | null
flags: Record<number, MatrixCell>
}
interface FlagColumn {
id: number
name: string
code: string | null
category_id: number
category_name: string
propagation_type: string
required: boolean
}
interface MatrixData {
flags: FlagColumn[]
ingredients: MatrixIngredient[]
}
interface Props {
recipeId: number
categoryId?: number
}
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
const { token } = useAuth()
const queryClient = useQueryClient()
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
const { data: rawData, isLoading } = useQuery<MatrixData>({
queryKey: ['recipe-flag-matrix', recipeId],
queryFn: async () => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch matrix')
return res.json()
},
enabled: !!token && !!recipeId,
})
// Filter to specific category if provided
const data = rawData ? {
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
ingredients: rawData.ingredients,
} : undefined
const toggleMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
method: 'PUT',
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
})
if (!res.ok) throw new Error('Failed to update flag')
},
onMutate: ({ ingredientId, flagId }) => {
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
},
onSettled: (_data, _err, { ingredientId, flagId }) => {
setPendingCells(prev => {
const next = new Set(prev)
next.delete(`${ingredientId}-${flagId}`)
return next
})
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
method: 'POST',
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
})
if (!res.ok) throw new Error('Failed to toggle none')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
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>
// Group flags by category
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
for (const f of data.flags) {
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].flags.push(f)
}
// Group ingredients by sub-recipe
let currentSubRecipe = ''
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
for (const ing of data.ingredients) {
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
currentSubRecipe = ing.sub_recipe_name || ''
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
} else if (!ing.is_sub_recipe && currentSubRecipe) {
currentSubRecipe = ''
}
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
}
// Compute totals per flag
const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
for (const f of data.flags) {
const cat = categories[f.category_id]
if (cat.propagation === 'contains') {
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
totals[f.id] = { has: anyHas, unassessed: false }
} else {
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
}
}
// Check if "none" is set per ingredient per category
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
const catFlags = categories[catId]?.flags || []
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
}
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
}
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
const hasFlag = cell?.has_flag ?? false
const isUnassessed = cell?.is_unassessed ?? false
const isNone = cell?.is_none ?? false
const hasOpenSuggestion = cell?.has_open_suggestion ?? false
const cellStyle: React.CSSProperties = {
...styles.cell,
cursor: 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'background 0.15s',
position: 'relative' as const,
}
// Small amber dot for open suggestions (not already flagged or unassessed)
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
<span style={{
position: 'absolute', top: '2px', right: '2px',
width: '6px', height: '6px', borderRadius: '50%',
background: '#f59e0b',
}} title="Unreviewed allergen suggestion" />
) : null
const handleClick = () => {
if (isPending) return
if (isUnassessed) {
handleToggle(ingredientId, flagId, false)
} else {
handleToggle(ingredientId, flagId, hasFlag)
}
}
if (isUnassessed) {
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
}
if (isNone) {
return (
<td
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
onClick={handleClick}
>
{'\u2014'}{suggestionDot}
</td>
)
}
if (propagation === 'contains') {
return (
<td
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 || ''}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
</td>
)
} else {
return (
<td
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
</td>
)
}
}
const showNoneColumn = Object.values(categories).some(c => c.required)
return (
<div style={styles.container}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
</span>
</div>
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
<table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
<tr>
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => (
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
{Object.keys(categories).length > 1 && (
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
)}
<div>{f.code || f.name.substring(0, 4)}</div>
</th>
))}
{cat.required && (
<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>
</th>
)}
</>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
if (row.type === 'header') {
const noneColCount = Object.values(categories).filter(c => c.required).length
const totalCols = data.flags.length + noneColCount + 1
return (
<tr key={`h-${idx}`}>
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
</tr>
)
}
const ing = row.ingredient!
return (
<tr key={ing.ingredient_id + '-' + idx}>
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
{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.required && (
<td
key={`none-${catId}-${ing.ingredient_id}`}
style={{
...styles.cell,
borderLeft: '2px solid #e0e0e0',
cursor: 'pointer',
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
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}`}
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
>
N
</td>
)}
</>
))}
</tr>
)
})}
{/* Total row */}
<tr style={{ borderTop: '3px double #333' }}>
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => {
const t = totals[f.id]
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
if (cat.propagation === 'contains') {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
{t.has ? '\u2713' : ''}
</td>
} else {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
{t.has ? '\u2713' : '\u2717'}
</td>
}
})}
{cat.required && (
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
)}
</>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
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' },
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 },
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' },
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],
queryFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch invoice')
return res.json()
@ -553,14 +553,14 @@ export default function Review() {
// Direct URL with token - simpler approach
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
const { data: lineItems, refetch: refetchLineItems } = useQuery<LineItem[]>({
queryKey: ['invoice-line-items', id],
queryFn: async () => {
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')
return res.json()
@ -577,7 +577,7 @@ export default function Review() {
queryKey: ['invoice-stock-history', id],
queryFn: async () => {
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')
return res.json()
@ -589,7 +589,7 @@ export default function Review() {
queryKey: ['invoice-duplicates', id],
queryFn: async () => {
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')
return res.json()
@ -601,7 +601,7 @@ export default function Review() {
queryKey: ['invoice-ocr-data', id],
queryFn: async () => {
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')
return res.json()
@ -613,7 +613,7 @@ export default function Review() {
queryKey: ['settings'],
queryFn: async () => {
const res = await fetch('/kitchen/api/settings/', {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch settings')
return res.json()
@ -639,7 +639,7 @@ export default function Review() {
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
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 }),
})
.then(res => res.ok ? res.json() : [])
@ -886,7 +886,7 @@ export default function Review() {
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) return []
return res.json()
@ -916,7 +916,7 @@ export default function Review() {
queryKey: ['po-match', id],
queryFn: async () => {
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 }
return res.json()
@ -945,7 +945,7 @@ export default function Review() {
const pollInterval = setInterval(async () => {
try {
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (checkRes.ok) {
const inv = await checkRes.json()
@ -971,8 +971,8 @@ export default function Review() {
try {
// Fetch the PDF
const pdfUrl = `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token)}`
const response = await fetch(pdfUrl)
const pdfUrl = `/kitchen/api/invoices/${id}/file`
const response = await fetch(pdfUrl, { credentials: 'include' })
const arrayBuffer = await response.arrayBuffer()
// Load the PDF document
@ -1043,8 +1043,8 @@ export default function Review() {
mutationFn: async (data: Partial<Invoice>) => {
const res = await fetch(`/kitchen/api/invoices/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
@ -1069,7 +1069,7 @@ export default function Review() {
mutationFn: async () => {
const res = await fetch(`/kitchen/api/invoices/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to delete')
return res.json()
@ -1084,8 +1084,8 @@ export default function Review() {
mutationFn: async ({ itemId, data }: { itemId: number; data: Partial<LineItem> }) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
@ -1106,8 +1106,8 @@ export default function Review() {
mutationFn: async (data: Partial<LineItem>) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
@ -1127,8 +1127,8 @@ export default function Review() {
mutationFn: async (itemId: number) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
},
})
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 }) => {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ portion_description: portionDesc || null }),
@ -1163,8 +1163,8 @@ export default function Review() {
mutationFn: async (name: string) => {
const res = await fetch('/kitchen/api/suppliers/', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
@ -1184,8 +1184,8 @@ export default function Review() {
mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => {
const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ alias, invoice_id: invoiceId }),
@ -1203,8 +1203,8 @@ export default function Review() {
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ alias }),
@ -1230,7 +1230,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: parseInt(id!) }),
})
if (!res.ok) throw new Error('Failed to link PO')
@ -1244,7 +1244,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to unlink PO')
refetchPoMatch()
@ -1388,7 +1388,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
@ -1425,7 +1425,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
@ -1458,7 +1458,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
@ -1486,7 +1486,7 @@ export default function Review() {
setShowInvoiceNumberModal(true)
try {
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')
const data = await res.json()
@ -1515,7 +1515,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
@ -1533,7 +1533,7 @@ export default function Review() {
const pollInterval = setInterval(async () => {
try {
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (checkRes.ok) {
const inv = await checkRes.json()
@ -1567,7 +1567,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
@ -1758,9 +1758,9 @@ export default function Review() {
try {
const res = await fetch('/kitchen/api/invoices/line-items/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query,
@ -1783,8 +1783,8 @@ export default function Review() {
const promises = lineItems.map(item =>
fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_non_stock: !markAsStock }),
@ -1806,7 +1806,7 @@ export default function Review() {
setAiMatchResults([])
try {
const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
@ -1829,7 +1829,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) {
let detail = 'AI analysis failed'
@ -1907,7 +1907,7 @@ export default function Review() {
setDefinitionLoading(true)
try {
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (res.ok) {
const def = await res.json()
@ -1928,7 +1928,7 @@ export default function Review() {
setAiPackSizeLoading(true)
try {
const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (packRes.ok) {
const packData = await packRes.json()
@ -1962,7 +1962,7 @@ export default function Review() {
setIngredientSearchLoading(true)
try {
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
@ -2055,7 +2055,7 @@ export default function Review() {
// Set ingredient_id on line item
await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: selectedIngredientId }),
})
@ -2083,7 +2083,7 @@ export default function Review() {
}
const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData),
})
if (!srcRes.ok) {
@ -2610,7 +2610,7 @@ export default function Review() {
)}
{isPDF && imageUrl && (
<a
href={`/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}`}
href={`/kitchen/api/invoices/${id}/file`}
target="_blank"
rel="noopener noreferrer"
style={styles.openPdfLink}
@ -3094,9 +3094,9 @@ export default function Review() {
try {
await fetch(`/kitchen/api/invoices/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ notes: invoiceNotes })
})
@ -4560,7 +4560,7 @@ export default function Review() {
width: '100%',
}}>
<img
src={`${imageUrl.split('?')[0]}?token=${encodeURIComponent(token || '')}`}
src={`${imageUrl.split('?')[0]}`}
alt="Line item location"
style={{
width: '100%',
@ -4675,7 +4675,7 @@ export default function Review() {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
credentials: 'include'
})
if (res.ok) {
const result = await res.json()

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ export default function Suppliers() {
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
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 }) => {
const res = await fetch('/kitchen/api/suppliers/', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
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 }) => {
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
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) => {
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to delete supplier')
return res.json()

View file

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

View file

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

View file

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

View file

@ -152,7 +152,7 @@ export default function BookingsStats() {
queryKey: ['resos-stats', submittedFromDate, submittedToDate],
queryFn: async () => {
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')
return res.json()
@ -183,7 +183,7 @@ export default function BookingsStats() {
queryFn: async () => {
const prevDates = getPreviousPeriodDates()
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')
return res.json()
@ -196,7 +196,7 @@ export default function BookingsStats() {
queryKey: ['resos-bookings', selectedDate],
queryFn: async () => {
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')
return res.json()

View file

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

View file

@ -67,7 +67,7 @@ export default function ResidentsTableChart() {
queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache
queryFn: async () => {
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')
return res.json()

View file

@ -116,12 +116,12 @@ export default function ResosData() {
queryKey: ['resos-settings'],
queryFn: async () => {
const res = await fetch('/kitchen/api/resos/settings', {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch settings')
return res.json()
},
enabled: !!token,
enabled: true,
})
// 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 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')
return res.json()
},
enabled: !!token,
enabled: true,
})
// 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 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')
return res.json()
},
enabled: !!token,
enabled: true,
})
// Fetch calendar events for the month
@ -168,7 +168,7 @@ export default function ResosData() {
const lastDay = new Date(year, month, 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}`, {
headers: { Authorization: `Bearer ${token}` }
credentials: 'include'
})
if (!res.ok) throw new Error('Failed to fetch calendar events')
return res.json()
@ -182,7 +182,7 @@ export default function ResosData() {
queryFn: async () => {
if (!selectedDate) return []
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')
return res.json()
@ -195,7 +195,7 @@ export default function ResosData() {
queryKey: ['resos-all-opening-hours'],
queryFn: async () => {
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')
return res.json()
@ -231,7 +231,7 @@ export default function ResosData() {
queryFn: async () => {
if (!selectedDate) return []
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')
return res.json()
@ -1324,7 +1324,7 @@ export default function ResosData() {
if (confirm('Delete this event?')) {
await fetch(`/kitchen/api/calendar-events/${editingEvent.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
credentials: 'include'
})
setShowEventModal(false)
refetchEvents()
@ -1365,8 +1365,8 @@ export default function ResosData() {
await fetch(url, {
method,
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventForm)

View file

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