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:
parent
6f6e16c88f
commit
ba075276b1
57 changed files with 15427 additions and 15274 deletions
|
|
@ -5,7 +5,7 @@ import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Query, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -282,15 +282,21 @@ async def delete_backup(
|
||||||
@router.get("/{backup_id}/download")
|
@router.get("/{backup_id}/download")
|
||||||
async def download_backup(
|
async def download_backup(
|
||||||
backup_id: int,
|
backup_id: int,
|
||||||
token: str,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Download a backup file. Auth via token query param for direct browser downloads."""
|
"""Download a backup file. Cookie auth preferred; ?token= accepted as fallback."""
|
||||||
from auth import get_current_user, require_cap_from_token
|
current_user = None
|
||||||
|
if token:
|
||||||
current_user = await get_current_user_from_token(token, db)
|
current_user = await get_current_user_from_token(token, db)
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
try:
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, text, and_, or_, delete, update
|
from sqlalchemy import select, func, text, and_, or_, delete, update
|
||||||
|
|
@ -783,14 +783,20 @@ async def get_ingredient(
|
||||||
@router.get("/{ingredient_id}/label-image")
|
@router.get("/{ingredient_id}/label-image")
|
||||||
async def get_label_image(
|
async def get_label_image(
|
||||||
ingredient_id: int,
|
ingredient_id: int,
|
||||||
token: str,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Serve the stored label image for a prepackaged ingredient.
|
"""Serve the stored label image for a prepackaged ingredient. Cookie auth preferred; ?token= accepted as fallback."""
|
||||||
Uses token query param for auth (allows window.open / img src usage)."""
|
|
||||||
import os
|
import os
|
||||||
from auth import get_current_user, require_cap_from_token
|
user = None
|
||||||
user = await get_current_user_from_token(token, db)
|
if token:
|
||||||
|
user = await get_current_user_from_token(token, db)
|
||||||
|
if not user:
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = "/app/data"
|
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
|
||||||
|
|
||||||
|
|
||||||
# Response Models
|
# Response Models
|
||||||
|
|
@ -549,7 +549,11 @@ async def upload_invoice(
|
||||||
status=InvoiceStatus.PENDING
|
status=InvoiceStatus.PENDING
|
||||||
)
|
)
|
||||||
db.add(invoice)
|
db.add(invoice)
|
||||||
await db.commit()
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
os.remove(filepath)
|
||||||
|
raise
|
||||||
await db.refresh(invoice)
|
await db.refresh(invoice)
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
|
|
@ -794,19 +798,22 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Ingredient price auto-update failed (non-critical): {e}")
|
logger.warning(f"Ingredient price auto-update failed (non-critical): {e}")
|
||||||
|
|
||||||
# Run duplicate detection
|
# Run duplicate detection (non-critical — log and continue on failure)
|
||||||
detector = DuplicateDetector(db, kitchen_id)
|
try:
|
||||||
duplicates = await detector.check_duplicates(invoice)
|
detector = DuplicateDetector(db, kitchen_id)
|
||||||
|
duplicates = await detector.check_duplicates(invoice)
|
||||||
|
|
||||||
if duplicates["firm_duplicate"]:
|
if duplicates["firm_duplicate"]:
|
||||||
invoice.duplicate_status = "firm_duplicate"
|
invoice.duplicate_status = "firm_duplicate"
|
||||||
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
|
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
|
||||||
elif duplicates["possible_duplicates"]:
|
elif duplicates["possible_duplicates"]:
|
||||||
invoice.duplicate_status = "possible_duplicate"
|
invoice.duplicate_status = "possible_duplicate"
|
||||||
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
|
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
|
||||||
|
|
||||||
if duplicates["related_documents"]:
|
if duplicates["related_documents"]:
|
||||||
invoice.related_document_id = duplicates["related_documents"][0].id
|
invoice.related_document_id = duplicates["related_documents"][0].id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Duplicate detection failed for invoice {invoice_id} (non-critical): {e}")
|
||||||
|
|
||||||
invoice.status = InvoiceStatus.PROCESSED
|
invoice.status = InvoiceStatus.PROCESSED
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -815,13 +822,17 @@ async def process_invoice_background(invoice_id: int, image_path: str, kitchen_i
|
||||||
f"duplicate_status={invoice.duplicate_status}")
|
f"duplicate_status={invoice.duplicate_status}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"OCR processing error for invoice {invoice_id}: {e}")
|
logger.error(f"OCR processing error for invoice {invoice_id}: {e}", exc_info=True)
|
||||||
stmt = select(Invoice).where(Invoice.id == invoice_id)
|
try:
|
||||||
db_result = await db.execute(stmt)
|
await db.rollback()
|
||||||
invoice = db_result.scalar_one()
|
stmt = select(Invoice).where(Invoice.id == invoice_id)
|
||||||
invoice.status = InvoiceStatus.PROCESSED
|
db_result = await db.execute(stmt)
|
||||||
invoice.ocr_raw_text = f"Error: {str(e)}"
|
invoice = db_result.scalar_one()
|
||||||
await db.commit()
|
invoice.status = InvoiceStatus.PROCESSED
|
||||||
|
invoice.ocr_raw_text = f"Error: {str(e)}"
|
||||||
|
await db.commit()
|
||||||
|
except Exception as update_err:
|
||||||
|
logger.error(f"Failed to update invoice {invoice_id} status after OCR error: {update_err}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=InvoiceListResponse)
|
@router.get("/", response_model=InvoiceListResponse)
|
||||||
|
|
@ -2106,19 +2117,26 @@ async def get_invoice_ocr_data(
|
||||||
async def get_line_item_preview(
|
async def get_line_item_preview(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
line_number: int,
|
line_number: int,
|
||||||
token: str,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get a cropped image preview of a specific line item from the invoice OCR bounding box."""
|
"""Get a cropped image preview of a specific line item from the invoice OCR bounding box."""
|
||||||
import json as json_module
|
import json as json_module
|
||||||
import io
|
import io
|
||||||
from auth import get_current_user, require_cap_from_token
|
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from services.file_archival_service import FileArchivalService
|
from services.file_archival_service import FileArchivalService
|
||||||
|
|
||||||
current_user = await get_current_user_from_token(token, db)
|
current_user = None
|
||||||
|
if token:
|
||||||
|
current_user = await get_current_user_from_token(token, db)
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
try:
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
||||||
|
|
||||||
|
|
@ -2220,13 +2238,13 @@ async def get_line_item_field_preview(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
line_number: int,
|
line_number: int,
|
||||||
field_name: str,
|
field_name: str,
|
||||||
token: str,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get a cropped image preview of a specific field within a line item (e.g. product_code)."""
|
"""Get a cropped image preview of a specific field within a line item (e.g. product_code)."""
|
||||||
import json as json_module
|
import json as json_module
|
||||||
import io
|
import io
|
||||||
from auth import get_current_user, require_cap_from_token
|
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from services.file_archival_service import FileArchivalService
|
from services.file_archival_service import FileArchivalService
|
||||||
|
|
||||||
|
|
@ -2234,9 +2252,16 @@ async def get_line_item_field_preview(
|
||||||
if not azure_key:
|
if not azure_key:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}")
|
raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}")
|
||||||
|
|
||||||
current_user = await get_current_user_from_token(token, db)
|
current_user = None
|
||||||
|
if token:
|
||||||
|
current_user = await get_current_user_from_token(token, db)
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
try:
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
||||||
|
|
||||||
|
|
@ -2387,11 +2412,11 @@ async def parse_dates_from_ocr(
|
||||||
|
|
||||||
|
|
||||||
def _generalize_invoice_number_pattern(sample: str) -> str:
|
def _generalize_invoice_number_pattern(sample: str) -> str:
|
||||||
"""
|
r"""
|
||||||
Convert a known invoice number into a regex that matches similar-shaped numbers.
|
Convert a known invoice number into a regex that matches similar-shaped numbers.
|
||||||
Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers.
|
Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers.
|
||||||
e.g. 'ID304574' → r'\bID\d{5,7}\b'
|
e.g. 'ID304574' -> r'\bID\d{5,7}\b'
|
||||||
'INV-00123' → r'\bINV-\d{4,6}\b'
|
'INV-00123' -> r'\bINV-\d{4,6}\b'
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
i = 0
|
i = 0
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, or_, func, delete
|
from sqlalchemy import select, and_, or_, func, delete
|
||||||
|
|
@ -661,13 +661,19 @@ def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str =
|
||||||
@router.get("/{po_id}/preview")
|
@router.get("/{po_id}/preview")
|
||||||
async def preview_purchase_order(
|
async def preview_purchase_order(
|
||||||
po_id: int,
|
po_id: int,
|
||||||
token: Optional[str] = None,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return a print-friendly HTML preview of the purchase order (query-param auth)."""
|
"""Return a print-friendly HTML preview of the purchase order. Cookie auth preferred; ?token= accepted as fallback."""
|
||||||
if not token:
|
current_user = None
|
||||||
raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token")
|
if token:
|
||||||
current_user = await get_current_user_from_token(token, db)
|
current_user = await get_current_user_from_token(token, db)
|
||||||
|
if not current_user:
|
||||||
|
try:
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, text, and_, delete
|
from sqlalchemy import select, func, text, and_, delete
|
||||||
|
|
@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
DATA_DIR = "/app/data"
|
DATA_DIR = os.getenv("DATA_DIR", "/app/data")
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -686,16 +686,33 @@ async def list_recipes(
|
||||||
result = await db.execute(query.order_by(Recipe.name))
|
result = await db.execute(query.order_by(Recipe.name))
|
||||||
recipes = result.scalars().all()
|
recipes = result.scalars().all()
|
||||||
|
|
||||||
|
# Batch-load latest cost snapshot per recipe (1 query vs N)
|
||||||
|
recipe_ids = [r.id for r in recipes]
|
||||||
|
snap_map: dict[int, RecipeCostSnapshot] = {}
|
||||||
|
if recipe_ids:
|
||||||
|
latest_subq = (
|
||||||
|
select(
|
||||||
|
RecipeCostSnapshot.recipe_id,
|
||||||
|
func.max(RecipeCostSnapshot.snapshot_date).label("max_date"),
|
||||||
|
)
|
||||||
|
.where(RecipeCostSnapshot.recipe_id.in_(recipe_ids))
|
||||||
|
.group_by(RecipeCostSnapshot.recipe_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
snap_rows = await db.execute(
|
||||||
|
select(RecipeCostSnapshot).join(
|
||||||
|
latest_subq,
|
||||||
|
and_(
|
||||||
|
RecipeCostSnapshot.recipe_id == latest_subq.c.recipe_id,
|
||||||
|
RecipeCostSnapshot.snapshot_date == latest_subq.c.max_date,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
snap_map = {s.recipe_id: s for s in snap_rows.scalars().all()}
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in recipes:
|
for r in recipes:
|
||||||
# Get latest cost snapshot
|
snap = snap_map.get(r.id)
|
||||||
snap_result = await db.execute(
|
|
||||||
select(RecipeCostSnapshot)
|
|
||||||
.where(RecipeCostSnapshot.recipe_id == r.id)
|
|
||||||
.order_by(RecipeCostSnapshot.snapshot_date.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
snap = snap_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Get flag summary (lightweight)
|
# Get flag summary (lightweight)
|
||||||
from api.food_flags import compute_recipe_flags
|
from api.food_flags import compute_recipe_flags
|
||||||
|
|
@ -1529,13 +1546,18 @@ async def upload_image(
|
||||||
async def serve_image(
|
async def serve_image(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
image_id: int,
|
image_id: int,
|
||||||
|
request: Request,
|
||||||
token: Optional[str] = Query(None),
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# img tags can't send Authorization header, so auth via query param
|
user = None
|
||||||
if not token:
|
if token:
|
||||||
raise HTTPException(401, "Not authenticated")
|
user = await get_current_user_from_token(token, db)
|
||||||
user = await get_current_user_from_token(token, db)
|
if not user:
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
await _get_recipe(recipe_id, user.kitchen_id, db)
|
await _get_recipe(recipe_id, user.kitchen_id, db)
|
||||||
|
|
@ -1728,6 +1750,8 @@ async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional
|
||||||
needed_unit = sr.portions_needed_unit or child_output_unit
|
needed_unit = sr.portions_needed_unit or child_output_unit
|
||||||
portions_needed_raw = float(sr.portions_needed) * scale_factor
|
portions_needed_raw = float(sr.portions_needed) * scale_factor
|
||||||
portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit)
|
portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit)
|
||||||
|
if not child_output_qty:
|
||||||
|
logger.warning(f"Recipe {child.id} ({child.name!r}) has zero yield qty — cost contribution zeroed in parent recipe")
|
||||||
scale_ratio = portions_needed / child_output_qty if child_output_qty else 0
|
scale_ratio = portions_needed / child_output_qty if child_output_qty else 0
|
||||||
cost_contribution = float(child_total) * scale_ratio if child_total else None
|
cost_contribution = float(child_total) * scale_ratio if child_total else None
|
||||||
cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None
|
cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None
|
||||||
|
|
@ -2098,14 +2122,19 @@ async def backfill_invoice_references(
|
||||||
@router.get("/{recipe_id}/print")
|
@router.get("/{recipe_id}/print")
|
||||||
async def print_recipe(
|
async def print_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
|
request: Request,
|
||||||
format: str = Query("full"), # "full" | "kitchen"
|
format: str = Query("full"), # "full" | "kitchen"
|
||||||
token: Optional[str] = Query(None),
|
token: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# window.open() can't send Authorization header, so auth via query param
|
user = None
|
||||||
if not token:
|
if token:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
user = await get_current_user_from_token(token, db)
|
||||||
user = await get_current_user_from_token(token, db)
|
if not user:
|
||||||
|
try:
|
||||||
|
user = await get_current_user(request)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
|
recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
|
||||||
|
|
@ -2116,11 +2145,11 @@ async def print_recipe(
|
||||||
from api.food_flags import compute_recipe_flags
|
from api.food_flags import compute_recipe_flags
|
||||||
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
|
flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
|
||||||
|
|
||||||
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token)
|
html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id)
|
||||||
return HTMLResponse(content=html)
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str:
|
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0) -> str:
|
||||||
"""Generate print-optimised HTML for a recipe."""
|
"""Generate print-optimised HTML for a recipe."""
|
||||||
esc = html_escape
|
esc = html_escape
|
||||||
name = esc(recipe_data.get("name", ""))
|
name = esc(recipe_data.get("name", ""))
|
||||||
|
|
@ -2204,7 +2233,7 @@ def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str =
|
||||||
if not plating_images:
|
if not plating_images:
|
||||||
plating_images = recipe_data.get("images", [])[:1]
|
plating_images = recipe_data.get("images", [])[:1]
|
||||||
for img in plating_images:
|
for img in plating_images:
|
||||||
img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}"
|
img_url = f"/kitchen/api/recipes/{recipe_id}/images/{img['id']}"
|
||||||
images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
|
images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
|
||||||
|
|
||||||
time_info = ""
|
time_info = ""
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ if DATABASE_URL.startswith("postgresql://"):
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=10, # Default is 5
|
pool_size=int(os.getenv("DB_POOL_SIZE", "10")),
|
||||||
max_overflow=20, # Default is 10 - allows burst to 30 connections
|
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
||||||
pool_pre_ping=True # Verify connections are alive before use
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
|
import React from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import AuthGate from './components/AuthGate'
|
import AuthGate, { useAuth } from './components/AuthGate'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
import { can } from './types'
|
||||||
|
|
||||||
// Re-export so existing components can keep `import { useAuth } from '../App'`
|
// Re-export so existing components can keep `import { useAuth } from '../App'`
|
||||||
export { useAuth } from './components/AuthGate'
|
export { useAuth }
|
||||||
|
|
||||||
|
function ProtectedRoute({ cap, element }: { cap: string; element: React.ReactElement }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
return can(user, cap) ? element : <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
|
@ -56,48 +63,48 @@ export default function App() {
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
|
||||||
{/* Invoices */}
|
{/* Invoices */}
|
||||||
<Route path="/upload" element={<Upload />} />
|
<Route path="/upload" element={<ProtectedRoute cap="invoices" element={<Upload />} />} />
|
||||||
<Route path="/invoices" element={<InvoiceList />} />
|
<Route path="/invoices" element={<ProtectedRoute cap="invoices" element={<InvoiceList />} />} />
|
||||||
<Route path="/invoice/:id" element={<Review />} />
|
<Route path="/invoice/:id" element={<ProtectedRoute cap="invoices" element={<Review />} />} />
|
||||||
<Route path="/disputes" element={<Disputes />} />
|
<Route path="/disputes" element={<ProtectedRoute cap="disputes" element={<Disputes />} />} />
|
||||||
<Route path="/search/invoices" element={<SearchInvoices />} />
|
<Route path="/search/invoices" element={<ProtectedRoute cap="invoices" element={<SearchInvoices />} />} />
|
||||||
<Route path="/search/line-items" element={<SearchLineItems />} />
|
<Route path="/search/line-items" element={<ProtectedRoute cap="invoices" element={<SearchLineItems />} />} />
|
||||||
<Route path="/purchase-orders" element={<PurchaseOrderList />} />
|
<Route path="/purchase-orders" element={<ProtectedRoute cap="orders" element={<PurchaseOrderList />} />} />
|
||||||
|
|
||||||
{/* Reports */}
|
{/* Reports */}
|
||||||
<Route path="/gp" element={<GPReport />} />
|
<Route path="/gp" element={<ProtectedRoute cap="view" element={<GPReport />} />} />
|
||||||
<Route path="/purchases-report" element={<PurchasesReport />} />
|
<Route path="/purchases-report" element={<ProtectedRoute cap="view" element={<PurchasesReport />} />} />
|
||||||
<Route path="/purchases" element={<Purchases />} />
|
<Route path="/purchases" element={<ProtectedRoute cap="invoices" element={<Purchases />} />} />
|
||||||
<Route path="/purchases/reconcile" element={<ReconcilePurchases />} />
|
<Route path="/purchases/reconcile" element={<ProtectedRoute cap="view" element={<ReconcilePurchases />} />} />
|
||||||
<Route path="/allowances-report" element={<AllowancesReport />} />
|
<Route path="/allowances-report" element={<ProtectedRoute cap="view" element={<AllowancesReport />} />} />
|
||||||
<Route path="/sales-gp" element={<SalesGPReport />} />
|
<Route path="/sales-gp" element={<ProtectedRoute cap="view" element={<SalesGPReport />} />} />
|
||||||
<Route path="/usage-variance" element={<UsageVarianceReport />} />
|
<Route path="/usage-variance" element={<ProtectedRoute cap="view" element={<UsageVarianceReport />} />} />
|
||||||
|
|
||||||
{/* Kitchen */}
|
{/* Kitchen */}
|
||||||
<Route path="/logbook" element={<WastageLogbook />} />
|
<Route path="/logbook" element={<ProtectedRoute cap="logbook" element={<WastageLogbook />} />} />
|
||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<ProtectedRoute cap="logbook" element={<Budget />} />} />
|
||||||
<Route path="/event-orders" element={<EventOrders />} />
|
<Route path="/event-orders" element={<ProtectedRoute cap="orders" element={<EventOrders />} />} />
|
||||||
<Route path="/event-orders/:id" element={<EventOrderEditor />} />
|
<Route path="/event-orders/:id" element={<ProtectedRoute cap="orders" element={<EventOrderEditor />} />} />
|
||||||
|
|
||||||
{/* Bookings */}
|
{/* Bookings */}
|
||||||
<Route path="/resos" element={<ResosData />} />
|
<Route path="/resos" element={<ProtectedRoute cap="view" element={<ResosData />} />} />
|
||||||
<Route path="/resos-stats" element={<BookingsStats />} />
|
<Route path="/resos-stats" element={<ProtectedRoute cap="view" element={<BookingsStats />} />} />
|
||||||
<Route path="/residents-table-chart" element={<ResidentsTableChart />} />
|
<Route path="/residents-table-chart" element={<ProtectedRoute cap="view" element={<ResidentsTableChart />} />} />
|
||||||
<Route path="/newbook" element={<NewbookData />} />
|
<Route path="/newbook" element={<ProtectedRoute cap="view" element={<NewbookData />} />} />
|
||||||
|
|
||||||
{/* Recipes */}
|
{/* Recipes */}
|
||||||
<Route path="/ingredients" element={<Ingredients />} />
|
<Route path="/ingredients" element={<ProtectedRoute cap="recipes" element={<Ingredients />} />} />
|
||||||
<Route path="/recipes" element={<RecipeList />} />
|
<Route path="/recipes" element={<ProtectedRoute cap="recipes" element={<RecipeList />} />} />
|
||||||
<Route path="/recipes/:id" element={<RecipeEditor />} />
|
<Route path="/recipes/:id" element={<ProtectedRoute cap="recipes" element={<RecipeEditor />} />} />
|
||||||
<Route path="/dishes" element={<DishList />} />
|
<Route path="/dishes" element={<ProtectedRoute cap="recipes" element={<DishList />} />} />
|
||||||
<Route path="/dishes/:id" element={<DishEditor />} />
|
<Route path="/dishes/:id" element={<ProtectedRoute cap="recipes" element={<DishEditor />} />} />
|
||||||
<Route path="/menus" element={<MenuList />} />
|
<Route path="/menus" element={<ProtectedRoute cap="menus" element={<MenuList />} />} />
|
||||||
<Route path="/menus/:id" element={<MenuEditor />} />
|
<Route path="/menus/:id" element={<ProtectedRoute cap="menus" element={<MenuEditor />} />} />
|
||||||
<Route path="/allergens" element={<BulkAllergens />} />
|
<Route path="/allergens" element={<ProtectedRoute cap="recipes" element={<BulkAllergens />} />} />
|
||||||
<Route path="/price-impact" element={<PriceImpact />} />
|
<Route path="/price-impact" element={<ProtectedRoute cap="recipes" element={<PriceImpact />} />} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<ProtectedRoute cap="settings" element={<Settings />} />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export default function AllowancesReport() {
|
||||||
queryKey: ['allowances-summary', submittedFromDate, submittedToDate],
|
queryKey: ['allowances-summary', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/allowances/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/allowances/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch allowances summary')
|
if (!res.ok) throw new Error('Failed to fetch allowances summary')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -269,7 +269,7 @@ export default function AllowancesReport() {
|
||||||
queryKey: ['allowances-daily', submittedFromDate, submittedToDate],
|
queryKey: ['allowances-daily', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/allowances/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/allowances/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch chart data')
|
if (!res.ok) throw new Error('Failed to fetch chart data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -283,7 +283,7 @@ export default function AllowancesReport() {
|
||||||
queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate],
|
queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/disputes/period-summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/disputes/period-summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch disputes summary')
|
if (!res.ok) throw new Error('Failed to fetch disputes summary')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -255,12 +255,12 @@ export default function Budget() {
|
||||||
queryKey: ['budget', 'weekly', weekOffset],
|
queryKey: ['budget', 'weekly', weekOffset],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset}`, {
|
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch budget data')
|
if (!res.ok) throw new Error('Failed to fetch budget data')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch prior 2 weeks for chart comparison
|
// Fetch prior 2 weeks for chart comparison
|
||||||
|
|
@ -268,23 +268,23 @@ export default function Budget() {
|
||||||
queryKey: ['budget', 'weekly', weekOffset - 1],
|
queryKey: ['budget', 'weekly', weekOffset - 1],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 1}`, {
|
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 1}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
const { data: prevWeek2 } = useQuery<WeeklyBudgetResponse>({
|
const { data: prevWeek2 } = useQuery<WeeklyBudgetResponse>({
|
||||||
queryKey: ['budget', 'weekly', weekOffset - 2],
|
queryKey: ['budget', 'weekly', weekOffset - 2],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 2}`, {
|
const res = await fetch(`/kitchen/api/budget/weekly?week_offset=${weekOffset - 2}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const goToPreviousWeek = () => setWeekOffset((prev) => prev - 1)
|
const goToPreviousWeek = () => setWeekOffset((prev) => prev - 1)
|
||||||
|
|
@ -299,7 +299,7 @@ export default function Budget() {
|
||||||
queryKey: ['cover-overrides', 'weekly', weekOffset],
|
queryKey: ['cover-overrides', 'weekly', weekOffset],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/cover-overrides/weekly?week_offset=${weekOffset}`, {
|
const res = await fetch(`/kitchen/api/cover-overrides/weekly?week_offset=${weekOffset}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch override data')
|
if (!res.ok) throw new Error('Failed to fetch override data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -313,7 +313,7 @@ export default function Budget() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/kitchen/api/cost-distributions/weekly?week_start=${budgetData!.week_start}&week_end=${budgetData!.week_end}`,
|
`/kitchen/api/cost-distributions/weekly?week_start=${budgetData!.week_start}&week_end=${budgetData!.week_end}`,
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ credentials: 'include' }
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error('Failed to fetch distribution data')
|
if (!res.ok) throw new Error('Failed to fetch distribution data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -336,7 +336,7 @@ export default function Budget() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/kitchen/api/resos/resident-covers?start_date=${budgetData!.week_start}&end_date=${budgetData!.week_end}`,
|
`/kitchen/api/resos/resident-covers?start_date=${budgetData!.week_start}&end_date=${budgetData!.week_end}`,
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ credentials: 'include' }
|
||||||
)
|
)
|
||||||
if (!res.ok) return {}
|
if (!res.ok) return {}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -349,7 +349,7 @@ export default function Budget() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/cover-overrides/snapshot', {
|
const res = await fetch('/kitchen/api/cover-overrides/snapshot', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ week_offset: weekOffset }),
|
body: JSON.stringify({ week_offset: weekOffset }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to create snapshot')
|
if (!res.ok) throw new Error('Failed to create snapshot')
|
||||||
|
|
@ -385,7 +385,7 @@ export default function Budget() {
|
||||||
const [overrideDate, period] = key.split('|')
|
const [overrideDate, period] = key.split('|')
|
||||||
return fetch('/kitchen/api/cover-overrides', {
|
return fetch('/kitchen/api/cover-overrides', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ override_date: overrideDate, period, override_covers: value }),
|
body: JSON.stringify({ override_date: overrideDate, period, override_covers: value }),
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
@ -400,7 +400,7 @@ export default function Budget() {
|
||||||
const deleteOverride = async (id: number) => {
|
const deleteOverride = async (id: number) => {
|
||||||
await fetch(`/kitchen/api/cover-overrides/${id}`, {
|
await fetch(`/kitchen/api/cover-overrides/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
refetchOverrides()
|
refetchOverrides()
|
||||||
refetch()
|
refetch()
|
||||||
|
|
@ -409,7 +409,7 @@ export default function Budget() {
|
||||||
const saveSpendRate = async (period: string, food: number | null, drinks: number | null) => {
|
const saveSpendRate = async (period: string, food: number | null, drinks: number | null) => {
|
||||||
await fetch('/kitchen/api/cover-overrides/spend-rates', {
|
await fetch('/kitchen/api/cover-overrides/spend-rates', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ week_offset: weekOffset, period, food_spend: food, drinks_spend: drinks }),
|
body: JSON.stringify({ week_offset: weekOffset, period, food_spend: food, drinks_spend: drinks }),
|
||||||
})
|
})
|
||||||
refetchOverrides()
|
refetchOverrides()
|
||||||
|
|
@ -806,7 +806,7 @@ export default function Budget() {
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const inputGross = parseFloat(e.target.value)
|
const inputGross = parseFloat(e.target.value)
|
||||||
if (!isNaN(inputGross) && Math.abs(inputGross - grossVal) > 0.001) {
|
if (!isNaN(inputGross) && inputGross >= 0 && Math.abs(inputGross - grossVal) > 0.001) {
|
||||||
const netVal = Math.round((inputGross / overrideData.vat_rate) * 100) / 100
|
const netVal = Math.round((inputGross / overrideData.vat_rate) * 100) / 100
|
||||||
saveSpendRate(sr.period, netVal, null)
|
saveSpendRate(sr.period, netVal, null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,438 +1,438 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
interface FlagInfo {
|
interface FlagInfo {
|
||||||
id: number
|
id: number
|
||||||
food_flag_id: number
|
food_flag_id: number
|
||||||
flag_name: string
|
flag_name: string
|
||||||
flag_code: string | null
|
flag_code: string | null
|
||||||
category_name: string
|
category_name: string
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllergenSuggestion {
|
interface AllergenSuggestion {
|
||||||
flag_id: number
|
flag_id: number
|
||||||
flag_name: string
|
flag_name: string
|
||||||
flag_code: string | null
|
flag_code: string | null
|
||||||
category_name: string
|
category_name: string
|
||||||
matched_keywords: string[]
|
matched_keywords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IngredientItem {
|
interface IngredientItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
category_id: number | null
|
category_id: number | null
|
||||||
category_name: string | null
|
category_name: string | null
|
||||||
standard_unit: string
|
standard_unit: string
|
||||||
notes: string | null
|
notes: string | null
|
||||||
is_prepackaged: boolean
|
is_prepackaged: boolean
|
||||||
product_ingredients: string | null
|
product_ingredients: string | null
|
||||||
flags: FlagInfo[]
|
flags: FlagInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FoodFlagItem {
|
interface FoodFlagItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
code: string | null
|
code: string | null
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FoodFlagCategoryItem {
|
interface FoodFlagCategoryItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
required: boolean
|
required: boolean
|
||||||
flags: FoodFlagItem[]
|
flags: FoodFlagItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IngredientCategory {
|
interface IngredientCategory {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkAllergens() {
|
export default function BulkAllergens() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
const [showUnassessedOnly, setShowUnassessedOnly] = useState(false)
|
const [showUnassessedOnly, setShowUnassessedOnly] = useState(false)
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Fetch all non-archived ingredients
|
// Fetch all non-archived ingredients
|
||||||
const { data: ingredients } = useQuery<IngredientItem[]>({
|
const { data: ingredients } = useQuery<IngredientItem[]>({
|
||||||
queryKey: ['ingredients-bulk'],
|
queryKey: ['ingredients-bulk'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/ingredients?limit=9999', {
|
const res = await fetch('/kitchen/api/ingredients?limit=9999', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch ingredients')
|
if (!res.ok) throw new Error('Failed to fetch ingredients')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch ingredient categories
|
// Fetch ingredient categories
|
||||||
const { data: categories } = useQuery<IngredientCategory[]>({
|
const { data: categories } = useQuery<IngredientCategory[]>({
|
||||||
queryKey: ['ingredient-categories'],
|
queryKey: ['ingredient-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/ingredients/categories', {
|
const res = await fetch('/kitchen/api/ingredients/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch flag categories (only required ones shown as columns)
|
// Fetch flag categories (only required ones shown as columns)
|
||||||
const { data: flagCategories } = useQuery<FoodFlagCategoryItem[]>({
|
const { data: flagCategories } = useQuery<FoodFlagCategoryItem[]>({
|
||||||
queryKey: ['food-flag-categories-full'],
|
queryKey: ['food-flag-categories-full'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/categories', {
|
const res = await fetch('/kitchen/api/food-flags/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch flag categories')
|
if (!res.ok) throw new Error('Failed to fetch flag categories')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch bulk nones (ingredient_id -> category_ids where None is set)
|
// Fetch bulk nones (ingredient_id -> category_ids where None is set)
|
||||||
const { data: bulkNones } = useQuery<Record<number, number[]>>({
|
const { data: bulkNones } = useQuery<Record<number, number[]>>({
|
||||||
queryKey: ['bulk-nones'],
|
queryKey: ['bulk-nones'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/ingredients/bulk-nones', {
|
const res = await fetch('/kitchen/api/ingredients/bulk-nones', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return {}
|
if (!res.ok) return {}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch suggestions for ALL ingredients in bulk (single request)
|
// Fetch suggestions for ALL ingredients in bulk (single request)
|
||||||
const { data: allSuggestions } = useQuery<Record<number, AllergenSuggestion[]>>({
|
const { data: allSuggestions } = useQuery<Record<number, AllergenSuggestion[]>>({
|
||||||
queryKey: ['bulk-suggestions'],
|
queryKey: ['bulk-suggestions'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/suggest/bulk', {
|
const res = await fetch('/kitchen/api/food-flags/suggest/bulk', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return {}
|
if (!res.ok) return {}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toggle a flag on an ingredient
|
// Toggle a flag on an ingredient
|
||||||
const toggleFlagMutation = useMutation({
|
const toggleFlagMutation = useMutation({
|
||||||
mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => {
|
mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => {
|
||||||
// Get current flags for this ingredient
|
// Get current flags for this ingredient
|
||||||
const ing = ingredients?.find(i => i.id === ingredientId)
|
const ing = ingredients?.find(i => i.id === ingredientId)
|
||||||
const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || []
|
const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || []
|
||||||
|
|
||||||
let newFlagIds: number[]
|
let newFlagIds: number[]
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
newFlagIds = [...currentFlagIds, flagId]
|
newFlagIds = [...currentFlagIds, flagId]
|
||||||
} else {
|
} else {
|
||||||
newFlagIds = currentFlagIds.filter(id => id !== flagId)
|
newFlagIds = currentFlagIds.filter(id => id !== flagId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, {
|
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ food_flag_ids: newFlagIds }),
|
body: JSON.stringify({ food_flag_ids: newFlagIds }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update flags')
|
if (!res.ok) throw new Error('Failed to update flags')
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
|
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
|
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toggle None for a category on an ingredient
|
// Toggle None for a category on an ingredient
|
||||||
const toggleNoneMutation = useMutation({
|
const toggleNoneMutation = useMutation({
|
||||||
mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => {
|
mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, {
|
const res = await fetch(`/kitchen/api/ingredients/${ingredientId}/flags/none`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ category_id: categoryId }),
|
body: JSON.stringify({ category_id: categoryId }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to toggle none')
|
if (!res.ok) throw new Error('Failed to toggle none')
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
|
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
|
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleExpanded = (ing: IngredientItem) => {
|
const toggleExpanded = (ing: IngredientItem) => {
|
||||||
setExpandedId(expandedId === ing.id ? null : ing.id)
|
setExpandedId(expandedId === ing.id ? null : ing.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show required categories as column groups
|
// Only show required categories as column groups
|
||||||
const requiredCategories = flagCategories?.filter(c => c.required) || []
|
const requiredCategories = flagCategories?.filter(c => c.required) || []
|
||||||
|
|
||||||
// Build a flat list of flag columns
|
// Build a flat list of flag columns
|
||||||
const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = []
|
const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = []
|
||||||
for (const cat of requiredCategories) {
|
for (const cat of requiredCategories) {
|
||||||
for (const f of cat.flags) {
|
for (const f of cat.flags) {
|
||||||
flagColumns.push({
|
flagColumns.push({
|
||||||
flagId: f.id,
|
flagId: f.id,
|
||||||
flagName: f.name,
|
flagName: f.name,
|
||||||
flagCode: f.code,
|
flagCode: f.code,
|
||||||
categoryId: cat.id,
|
categoryId: cat.id,
|
||||||
categoryName: cat.name,
|
categoryName: cat.name,
|
||||||
propagation: cat.propagation_type,
|
propagation: cat.propagation_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter ingredients
|
// Filter ingredients
|
||||||
const filtered = (ingredients || []).filter(ing => {
|
const filtered = (ingredients || []).filter(ing => {
|
||||||
if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false
|
if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false
|
if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false
|
||||||
if (showUnassessedOnly) {
|
if (showUnassessedOnly) {
|
||||||
// Check if ingredient is unassessed for any required category
|
// Check if ingredient is unassessed for any required category
|
||||||
const nones = bulkNones?.[ing.id] || []
|
const nones = bulkNones?.[ing.id] || []
|
||||||
for (const cat of requiredCategories) {
|
for (const cat of requiredCategories) {
|
||||||
if (nones.includes(cat.id)) continue // None set for this category
|
if (nones.includes(cat.id)) continue // None set for this category
|
||||||
const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id))
|
const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id))
|
||||||
if (!hasFlagInCat) return true // Unassessed for this category
|
if (!hasFlagInCat) return true // Unassessed for this category
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<h2 style={{ margin: '0 0 0.75rem 0' }}>Bulk Allergen Assessment</h2>
|
<h2 style={{ margin: '0 0 0.75rem 0' }}>Bulk Allergen Assessment</h2>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div style={styles.filterBar}>
|
<div style={styles.filterBar}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search ingredients..."
|
placeholder="Search ingredients..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
/>
|
/>
|
||||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
|
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{categories?.map(c => (
|
{categories?.map(c => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.85rem', cursor: 'pointer' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showUnassessedOnly}
|
checked={showUnassessedOnly}
|
||||||
onChange={(e) => setShowUnassessedOnly(e.target.checked)}
|
onChange={(e) => setShowUnassessedOnly(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Unassessed only
|
Unassessed only
|
||||||
</label>
|
</label>
|
||||||
<span style={{ fontSize: '0.85rem', color: '#666', marginLeft: 'auto' }}>
|
<span style={{ fontSize: '0.85rem', color: '#666', marginLeft: 'auto' }}>
|
||||||
{filtered.length} ingredients
|
{filtered.length} ingredients
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{flagColumns.length === 0 ? (
|
{flagColumns.length === 0 ? (
|
||||||
<div style={styles.emptyState}>
|
<div style={styles.emptyState}>
|
||||||
No required flag categories found. Go to Settings > Food Flags and mark allergen categories as "Required".
|
No required flag categories found. Go to Settings > Food Flags and mark allergen categories as "Required".
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead style={{ position: 'sticky', top: 0, zIndex: 3 }}>
|
<thead style={{ position: 'sticky', top: 0, zIndex: 3 }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...styles.th, position: 'sticky', left: 0, background: '#fafafa', zIndex: 4, minWidth: '200px' }}>
|
<th style={{ ...styles.th, position: 'sticky', left: 0, background: '#fafafa', zIndex: 4, minWidth: '200px' }}>
|
||||||
Ingredient
|
Ingredient
|
||||||
</th>
|
</th>
|
||||||
{requiredCategories.map(cat => (
|
{requiredCategories.map(cat => (
|
||||||
<th
|
<th
|
||||||
key={`cat-none-${cat.id}`}
|
key={`cat-none-${cat.id}`}
|
||||||
style={{ ...styles.th, textAlign: 'center', fontSize: '0.7rem', background: '#f0fdf4', minWidth: '50px' }}
|
style={{ ...styles.th, textAlign: 'center', fontSize: '0.7rem', background: '#f0fdf4', minWidth: '50px' }}
|
||||||
title={`None apply for ${cat.name}`}
|
title={`None apply for ${cat.name}`}
|
||||||
>
|
>
|
||||||
None
|
None
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{flagColumns.map(col => (
|
{flagColumns.map(col => (
|
||||||
<th
|
<th
|
||||||
key={col.flagId}
|
key={col.flagId}
|
||||||
style={{ ...styles.th, textAlign: 'center' as const, minWidth: '45px', writingMode: 'vertical-lr' as const, fontSize: '0.7rem' }}
|
style={{ ...styles.th, textAlign: 'center' as const, minWidth: '45px', writingMode: 'vertical-lr' as const, fontSize: '0.7rem' }}
|
||||||
title={`${col.flagName} (${col.categoryName})`}
|
title={`${col.flagName} (${col.categoryName})`}
|
||||||
>
|
>
|
||||||
{col.flagCode || col.flagName}
|
{col.flagCode || col.flagName}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{filtered.map(ing => {
|
{filtered.map(ing => {
|
||||||
const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id))
|
const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id))
|
||||||
const nones = bulkNones?.[ing.id] || []
|
const nones = bulkNones?.[ing.id] || []
|
||||||
const isExpanded = expandedId === ing.id
|
const isExpanded = expandedId === ing.id
|
||||||
const ingSuggestions = allSuggestions?.[ing.id]
|
const ingSuggestions = allSuggestions?.[ing.id]
|
||||||
const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id))
|
const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id))
|
||||||
const hasPendingSuggestions = !!pendingSuggestions?.length
|
const hasPendingSuggestions = !!pendingSuggestions?.length
|
||||||
const totalCols = 1 + requiredCategories.length + flagColumns.length
|
const totalCols = 1 + requiredCategories.length + flagColumns.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tbody key={ing.id}>
|
<tbody key={ing.id}>
|
||||||
<tr style={styles.tr}>
|
<tr style={styles.tr}>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
...styles.td, position: 'sticky', left: 0, zIndex: 1, fontWeight: 500, cursor: 'pointer',
|
...styles.td, position: 'sticky', left: 0, zIndex: 1, fontWeight: 500, cursor: 'pointer',
|
||||||
background: isExpanded ? '#f0f7ff' : hasPendingSuggestions ? '#fffbeb' : 'white',
|
background: isExpanded ? '#f0f7ff' : hasPendingSuggestions ? '#fffbeb' : 'white',
|
||||||
borderLeft: hasPendingSuggestions ? '3px solid #f59e0b' : undefined,
|
borderLeft: hasPendingSuggestions ? '3px solid #f59e0b' : undefined,
|
||||||
}}
|
}}
|
||||||
onClick={() => toggleExpanded(ing)}
|
onClick={() => toggleExpanded(ing)}
|
||||||
title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'}
|
title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: '0.65rem', color: '#888', marginRight: '4px' }}>{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
<span style={{ fontSize: '0.65rem', color: '#888', marginRight: '4px' }}>{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||||
{ing.name}
|
{ing.name}
|
||||||
{ing.category_name && (
|
{ing.category_name && (
|
||||||
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '6px' }}>{ing.category_name}</span>
|
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '6px' }}>{ing.category_name}</span>
|
||||||
)}
|
)}
|
||||||
{ing.is_prepackaged && (
|
{ing.is_prepackaged && (
|
||||||
<span style={{ fontSize: '0.6rem', color: '#6366f1', marginLeft: '6px', fontWeight: 600 }}>PKG</span>
|
<span style={{ fontSize: '0.6rem', color: '#6366f1', marginLeft: '6px', fontWeight: 600 }}>PKG</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* None columns per required category */}
|
{/* None columns per required category */}
|
||||||
{requiredCategories.map(cat => {
|
{requiredCategories.map(cat => {
|
||||||
const isNone = nones.includes(cat.id)
|
const isNone = nones.includes(cat.id)
|
||||||
return (
|
return (
|
||||||
<td key={`none-${cat.id}`} style={{ ...styles.td, textAlign: 'center', background: isNone ? '#dcfce7' : undefined }}>
|
<td key={`none-${cat.id}`} style={{ ...styles.td, textAlign: 'center', background: isNone ? '#dcfce7' : undefined }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isNone}
|
checked={isNone}
|
||||||
onChange={() => toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })}
|
onChange={() => toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })}
|
||||||
disabled={toggleNoneMutation.isPending}
|
disabled={toggleNoneMutation.isPending}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
title={`None apply for ${cat.name}`}
|
title={`None apply for ${cat.name}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Flag columns */}
|
{/* Flag columns */}
|
||||||
{flagColumns.map(col => {
|
{flagColumns.map(col => {
|
||||||
const isChecked = ingFlagIds.has(col.flagId)
|
const isChecked = ingFlagIds.has(col.flagId)
|
||||||
const isNoneForCategory = nones.includes(col.categoryId)
|
const isNoneForCategory = nones.includes(col.categoryId)
|
||||||
const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId)
|
const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId)
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={col.flagId}
|
key={col.flagId}
|
||||||
style={{
|
style={{
|
||||||
...styles.td,
|
...styles.td,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
background: isChecked
|
background: isChecked
|
||||||
? (col.propagation === 'contains' ? '#fef2f2' : '#dcfce7')
|
? (col.propagation === 'contains' ? '#fef2f2' : '#dcfce7')
|
||||||
: isSuggested ? '#fffbeb'
|
: isSuggested ? '#fffbeb'
|
||||||
: isNoneForCategory ? '#f8f8f8' : undefined,
|
: isNoneForCategory ? '#f8f8f8' : undefined,
|
||||||
opacity: isNoneForCategory ? 0.4 : 1,
|
opacity: isNoneForCategory ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={() => toggleFlagMutation.mutate({
|
onChange={() => toggleFlagMutation.mutate({
|
||||||
ingredientId: ing.id,
|
ingredientId: ing.id,
|
||||||
flagId: col.flagId,
|
flagId: col.flagId,
|
||||||
action: isChecked ? 'remove' : 'add',
|
action: isChecked ? 'remove' : 'add',
|
||||||
})}
|
})}
|
||||||
disabled={isNoneForCategory || toggleFlagMutation.isPending}
|
disabled={isNoneForCategory || toggleFlagMutation.isPending}
|
||||||
style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }}
|
style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }}
|
||||||
title={col.flagName + (isSuggested ? ' (suggested)' : '')}
|
title={col.flagName + (isSuggested ? ' (suggested)' : '')}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Expanded detail row */}
|
{/* Expanded detail row */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={totalCols} style={{ padding: '0.5rem 0.75rem', background: '#f8fafc', borderBottom: '2px solid #e0e0e0' }}>
|
<td colSpan={totalCols} style={{ padding: '0.5rem 0.75rem', background: '#f8fafc', borderBottom: '2px solid #e0e0e0' }}>
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem' }}>
|
<div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem' }}>
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Notes</div>
|
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Notes</div>
|
||||||
<div style={{ color: ing.notes ? '#333' : '#bbb', whiteSpace: 'pre-wrap' }}>
|
<div style={{ color: ing.notes ? '#333' : '#bbb', whiteSpace: 'pre-wrap' }}>
|
||||||
{ing.notes || 'No notes'}
|
{ing.notes || 'No notes'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product ingredients */}
|
{/* Product ingredients */}
|
||||||
<div style={{ flex: 2 }}>
|
<div style={{ flex: 2 }}>
|
||||||
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>
|
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>
|
||||||
Label Ingredients {ing.is_prepackaged && <span style={{ color: '#6366f1' }}>(prepackaged)</span>}
|
Label Ingredients {ing.is_prepackaged && <span style={{ color: '#6366f1' }}>(prepackaged)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: ing.product_ingredients ? '#333' : '#bbb', whiteSpace: 'pre-wrap', maxHeight: '80px', overflow: 'auto' }}>
|
<div style={{ color: ing.product_ingredients ? '#333' : '#bbb', whiteSpace: 'pre-wrap', maxHeight: '80px', overflow: 'auto' }}>
|
||||||
{ing.product_ingredients || 'Not available'}
|
{ing.product_ingredients || 'Not available'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Allergen suggestions */}
|
{/* Allergen suggestions */}
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Keyword Suggestions</div>
|
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Keyword Suggestions</div>
|
||||||
{!pendingSuggestions?.length ? (
|
{!pendingSuggestions?.length ? (
|
||||||
<div style={{ color: '#bbb' }}>No suggestions</div>
|
<div style={{ color: '#bbb' }}>No suggestions</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||||
{pendingSuggestions.map(s => (
|
{pendingSuggestions.map(s => (
|
||||||
<div
|
<div
|
||||||
key={s.flag_id}
|
key={s.flag_id}
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.15rem 0.3rem', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '4px' }}
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.15rem 0.3rem', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '4px' }}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong style={{ fontSize: '0.75rem' }}>{s.flag_name}</strong>
|
<strong style={{ fontSize: '0.75rem' }}>{s.flag_name}</strong>
|
||||||
<span style={{ color: '#999', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
|
<span style={{ color: '#999', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
|
||||||
{s.matched_keywords.join(', ')}
|
{s.matched_keywords.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const cat = requiredCategories.find(c => c.flags.some(f => f.id === s.flag_id))
|
const cat = requiredCategories.find(c => c.flags.some(f => f.id === s.flag_id))
|
||||||
if (cat) toggleFlagMutation.mutate({ ingredientId: ing.id, flagId: s.flag_id, action: 'add' })
|
if (cat) toggleFlagMutation.mutate({ ingredientId: ing.id, flagId: s.flag_id, action: 'add' })
|
||||||
}}
|
}}
|
||||||
disabled={toggleFlagMutation.isPending}
|
disabled={toggleFlagMutation.isPending}
|
||||||
style={{ background: '#f59e0b', color: 'white', border: 'none', borderRadius: '3px', padding: '0.1rem 0.3rem', fontSize: '0.65rem', fontWeight: 600, cursor: 'pointer' }}
|
style={{ background: '#f59e0b', color: 'white', border: 'none', borderRadius: '3px', padding: '0.1rem 0.3rem', fontSize: '0.65rem', fontWeight: 600, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' },
|
page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' },
|
||||||
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const },
|
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const },
|
||||||
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
|
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
|
||||||
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
||||||
emptyState: { padding: '3rem', textAlign: 'center' as const, color: '#888', background: '#fafafa', borderRadius: '8px' },
|
emptyState: { padding: '3rem', textAlign: 'center' as const, color: '#888', background: '#fafafa', borderRadius: '8px' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
||||||
th: { padding: '0.5rem 0.4rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555' },
|
th: { padding: '0.5rem 0.4rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555' },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' },
|
td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,12 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC
|
||||||
queryKey: ['dishes-for-bulk'],
|
queryKey: ['dishes-for-bulk'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
|
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check flags for selected dishes
|
// Check flags for selected dishes
|
||||||
|
|
@ -47,7 +47,7 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC
|
||||||
const results: Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }> = {}
|
const results: Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }> = {}
|
||||||
for (const rid of selected) {
|
for (const rid of selected) {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/recipes/${rid}/flags`, {
|
const res = await fetch(`/kitchen/api/food-flags/recipes/${rid}/flags`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -78,7 +78,7 @@ export default function BulkPublishModal({ menuId, divisionId, divisionName, onC
|
||||||
})
|
})
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/bulk`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/bulk`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
division_id: divisionId,
|
division_id: divisionId,
|
||||||
confirmed_by_name: confirmedBy.trim(),
|
confirmed_by_name: confirmedBy.trim(),
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
// Load existing distribution
|
// Load existing distribution
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
.then(r => { if (!r.ok) throw new Error('Failed to load distribution'); return r.json() })
|
.then(r => { if (!r.ok) throw new Error('Failed to load distribution'); return r.json() })
|
||||||
.then((data: DistributionDetail) => {
|
.then((data: DistributionDetail) => {
|
||||||
|
|
@ -137,7 +137,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
// Load invoice availability
|
// Load invoice availability
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetch(`/kitchen/api/cost-distributions/invoice/${invoiceId}/availability`, {
|
fetch(`/kitchen/api/cost-distributions/invoice/${invoiceId}/availability`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
.then(r => { if (!r.ok) throw new Error('Failed to load invoice data'); return r.json() })
|
.then(r => { if (!r.ok) throw new Error('Failed to load invoice data'); return r.json() })
|
||||||
.then((data: InvoiceAvailability) => {
|
.then((data: InvoiceAvailability) => {
|
||||||
|
|
@ -278,7 +278,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
|
|
||||||
const res = await fetch('/kitchen/api/cost-distributions/', {
|
const res = await fetch('/kitchen/api/cost-distributions/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -302,7 +302,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ notes }),
|
body: JSON.stringify({ notes }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update')
|
if (!res.ok) throw new Error('Failed to update')
|
||||||
|
|
@ -322,7 +322,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -345,7 +345,7 @@ export default function CostDistributionModal({ isOpen, onClose, onSaved, invoic
|
||||||
if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount)
|
if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount)
|
||||||
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, {
|
const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ export default function CreateDisputeModal({
|
||||||
mutationFn: async (data: CreateDisputeRequest) => {
|
mutationFn: async (data: CreateDisputeRequest) => {
|
||||||
const res = await fetch('/kitchen/api/disputes', {
|
const res = await fetch('/kitchen/api/disputes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -137,19 +137,19 @@ export default function Dashboard() {
|
||||||
queryKey: ['resos-settings'],
|
queryKey: ['resos-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/resos/settings', {
|
const res = await fetch('/kitchen/api/resos/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch Resos settings')
|
if (!res.ok) throw new Error('Failed to fetch Resos settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery<DashboardData>({
|
const { data, isLoading, error } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/reports/dashboard', {
|
const res = await fetch('/kitchen/api/reports/dashboard', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch dashboard')
|
if (!res.ok) throw new Error('Failed to fetch dashboard')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -160,7 +160,7 @@ export default function Dashboard() {
|
||||||
queryKey: ['resos-dashboard-covers'],
|
queryKey: ['resos-dashboard-covers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/resos/dashboard/today-tomorrow', {
|
const res = await fetch('/kitchen/api/resos/dashboard/today-tomorrow', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch Resos covers')
|
if (!res.ok) throw new Error('Failed to fetch Resos covers')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -173,12 +173,12 @@ export default function Dashboard() {
|
||||||
queryKey: ['newbook-arrival-stats'],
|
queryKey: ['newbook-arrival-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/dashboard/arrivals?days=3', {
|
const res = await fetch('/kitchen/api/newbook/dashboard/arrivals?days=3', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch arrival stats')
|
if (!res.ok) throw new Error('Failed to fetch arrival stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -194,12 +194,12 @@ export default function Dashboard() {
|
||||||
queryKey: ['upcoming-events'],
|
queryKey: ['upcoming-events'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/calendar-events/dashboard/upcoming', {
|
const res = await fetch('/kitchen/api/calendar-events/dashboard/upcoming', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch upcoming events')
|
if (!res.ok) throw new Error('Failed to fetch upcoming events')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -218,12 +218,12 @@ export default function Dashboard() {
|
||||||
queryKey: ['recipe-dashboard-stats'],
|
queryKey: ['recipe-dashboard-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes/dashboard-stats', {
|
const res = await fetch('/kitchen/api/recipes/dashboard-stats', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch recipe stats')
|
if (!res.ok) throw new Error('Failed to fetch recipe stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -231,12 +231,12 @@ export default function Dashboard() {
|
||||||
queryKey: ['dispute-stats'],
|
queryKey: ['dispute-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/disputes/stats/summary', {
|
const res = await fetch('/kitchen/api/disputes/stats/summary', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch dispute stats')
|
if (!res.ok) throw new Error('Failed to fetch dispute stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -125,11 +125,11 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
|
||||||
const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({
|
const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({
|
||||||
queryKey: ['settings'],
|
queryKey: ['settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings', { headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch('/kitchen/api/settings', { credentials: 'include' })
|
||||||
if (!res.ok) return {}
|
if (!res.ok) return {}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -137,21 +137,21 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
|
||||||
queryKey: ['dispute', disputeId],
|
queryKey: ['dispute', disputeId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch dispute')
|
if (!res.ok) throw new Error('Failed to fetch dispute')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => {
|
mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
|
|
@ -174,8 +174,8 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
const res = await fetch(`/kitchen/api/disputes/${disputeId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -253,7 +253,7 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, {
|
const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
|
|
@ -292,8 +292,8 @@ export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: Dis
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, {
|
const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ export default function Disputes() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build query params
|
// Build query params
|
||||||
|
|
@ -85,12 +85,12 @@ export default function Disputes() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = queryString ? `/kitchen/api/disputes?${queryString}` : '/kitchen/api/disputes'
|
const url = queryString ? `/kitchen/api/disputes?${queryString}` : '/kitchen/api/disputes'
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch disputes')
|
if (!res.ok) throw new Error('Failed to fetch disputes')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Client-side filter for "not_resolved" - show all except resolved
|
// Client-side filter for "not_resolved" - show all except resolved
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,189 +1,189 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
interface EventOrderItem {
|
interface EventOrderItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
event_date: string | null
|
event_date: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
status: string
|
status: string
|
||||||
item_count: number
|
item_count: number
|
||||||
estimated_cost: number | null
|
estimated_cost: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventOrders() {
|
export default function EventOrders() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [formName, setFormName] = useState('')
|
const [formName, setFormName] = useState('')
|
||||||
const [formDate, setFormDate] = useState('')
|
const [formDate, setFormDate] = useState('')
|
||||||
const [formNotes, setFormNotes] = useState('')
|
const [formNotes, setFormNotes] = useState('')
|
||||||
|
|
||||||
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
|
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
|
||||||
queryKey: ['event-orders'],
|
queryKey: ['event-orders'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/event-orders', {
|
const res = await fetch('/kitchen/api/event-orders', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
|
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
|
||||||
const res = await fetch('/kitchen/api/event-orders', {
|
const res = await fetch('/kitchen/api/event-orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to create')
|
if (!res.ok) throw new Error('Failed to create')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['event-orders'] })
|
queryClient.invalidateQueries({ queryKey: ['event-orders'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
navigate(`/event-orders/${data.id}`)
|
navigate(`/event-orders/${data.id}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/event-orders/${id}`, {
|
const res = await fetch(`/kitchen/api/event-orders/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
DRAFT: '#f59e0b',
|
DRAFT: '#f59e0b',
|
||||||
FINALISED: '#3b82f6',
|
FINALISED: '#3b82f6',
|
||||||
ORDERED: '#22c55e',
|
ORDERED: '#22c55e',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h2 style={{ margin: 0 }}>Event Orders</h2>
|
<h2 style={{ margin: 0 }}>Event Orders</h2>
|
||||||
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
|
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
|
||||||
+ New Event Order
|
+ New Event Order
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={styles.loading}>Loading...</div>
|
<div style={styles.loading}>Loading...</div>
|
||||||
) : orders && orders.length > 0 ? (
|
) : orders && orders.length > 0 ? (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Name</th>
|
<th style={styles.th}>Name</th>
|
||||||
<th style={styles.th}>Date</th>
|
<th style={styles.th}>Date</th>
|
||||||
<th style={styles.th}>Status</th>
|
<th style={styles.th}>Status</th>
|
||||||
<th style={styles.th}>Recipes</th>
|
<th style={styles.th}>Recipes</th>
|
||||||
<th style={styles.th}>Actions</th>
|
<th style={styles.th}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map(o => (
|
{orders.map(o => (
|
||||||
<tr key={o.id} style={styles.tr} onClick={() => navigate(`/event-orders/${o.id}`)} >
|
<tr key={o.id} style={styles.tr} onClick={() => navigate(`/event-orders/${o.id}`)} >
|
||||||
<td style={{ ...styles.td, fontWeight: 500, cursor: 'pointer' }}>{o.name}</td>
|
<td style={{ ...styles.td, fontWeight: 500, cursor: 'pointer' }}>{o.name}</td>
|
||||||
<td style={styles.td}>{o.event_date || '-'}</td>
|
<td style={styles.td}>{o.event_date || '-'}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: statusColors[o.status] || '#ccc',
|
background: statusColors[o.status] || '#ccc',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
{o.status}
|
{o.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>{o.item_count}</td>
|
<td style={styles.td}>{o.item_count}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{o.status === 'DRAFT' && (
|
{o.status === 'DRAFT' && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
|
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
|
||||||
style={{ ...styles.smallBtn, color: '#e94560' }}
|
style={{ ...styles.smallBtn, color: '#e94560' }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
|
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
|
||||||
No event orders yet. Create one to start planning.
|
No event orders yet. Create one to start planning.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div style={styles.overlay}>
|
<div style={styles.overlay}>
|
||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<div style={styles.modalHeader}>
|
<div style={styles.modalHeader}>
|
||||||
<h3 style={{ margin: 0 }}>New Event Order</h3>
|
<h3 style={{ margin: 0 }}>New Event Order</h3>
|
||||||
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}>✕</button>
|
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.modalBody}>
|
<div style={styles.modalBody}>
|
||||||
<label style={styles.label}>Event Name *</label>
|
<label style={styles.label}>Event Name *</label>
|
||||||
<input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Wedding Reception 15th March" />
|
<input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Wedding Reception 15th March" />
|
||||||
<label style={styles.label}>Event Date</label>
|
<label style={styles.label}>Event Date</label>
|
||||||
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
|
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
|
||||||
<label style={styles.label}>Notes</label>
|
<label style={styles.label}>Notes</label>
|
||||||
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
|
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.modalFooter}>
|
<div style={styles.modalFooter}>
|
||||||
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
|
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => createMutation.mutate({
|
onClick={() => createMutation.mutate({
|
||||||
name: formName,
|
name: formName,
|
||||||
event_date: formDate || undefined,
|
event_date: formDate || undefined,
|
||||||
notes: formNotes || undefined,
|
notes: formNotes || undefined,
|
||||||
})}
|
})}
|
||||||
disabled={!formName || createMutation.isPending}
|
disabled={!formName || createMutation.isPending}
|
||||||
style={styles.primaryBtn}
|
style={styles.primaryBtn}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
|
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
||||||
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0', cursor: 'pointer' },
|
tr: { borderBottom: '1px solid #f0f0f0', cursor: 'pointer' },
|
||||||
td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem' },
|
td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem' },
|
||||||
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' },
|
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' },
|
||||||
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 },
|
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 },
|
||||||
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
|
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
|
||||||
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
|
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
|
||||||
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
||||||
modal: { background: 'white', borderRadius: '10px', width: '450px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
|
modal: { background: 'white', borderRadius: '10px', width: '450px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
|
||||||
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
||||||
modalBody: { padding: '1.25rem' },
|
modalBody: { padding: '1.25rem' },
|
||||||
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
|
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
|
||||||
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
||||||
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
|
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
|
||||||
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
|
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,14 +158,22 @@ export default function GPReport() {
|
||||||
const monthOptions = getMonthOptions()
|
const monthOptions = getMonthOptions()
|
||||||
|
|
||||||
// Allowances checkbox state - default: all checked EXCEPT wastage
|
// Allowances checkbox state - default: all checked EXCEPT wastage
|
||||||
const [allowancesSelection, setAllowancesSelection] = useState({
|
const _defaultAllowances = {
|
||||||
wastage: false, // Wastage: unchecked by default
|
wastage: false,
|
||||||
transfer: true, // Transfer: checked by default
|
transfer: true,
|
||||||
staffFood: true, // Staff Food: checked by default
|
staffFood: true,
|
||||||
manualAdjustment: true, // Manual Adjustment: checked by default
|
manualAdjustment: true,
|
||||||
disputes: true, // Open Disputes: checked by default
|
disputes: true,
|
||||||
cdDeductions: true, // Distributed Deductions: checked by default
|
cdDeductions: true,
|
||||||
cdReallocations: true // Distributed Reallocations: checked by default
|
cdReallocations: true,
|
||||||
|
}
|
||||||
|
const [allowancesSelection, setAllowancesSelection] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('gpreport_allowances_v1')
|
||||||
|
return stored ? { ..._defaultAllowances, ...JSON.parse(stored) } : _defaultAllowances
|
||||||
|
} catch {
|
||||||
|
return _defaultAllowances
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track if dates have changed since last generation
|
// Track if dates have changed since last generation
|
||||||
|
|
@ -318,7 +326,7 @@ export default function GPReport() {
|
||||||
queryKey: ['gp-range', submittedFromDate, submittedToDate],
|
queryKey: ['gp-range', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/gp/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/gp/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch GP data')
|
if (!res.ok) throw new Error('Failed to fetch GP data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -332,7 +340,7 @@ export default function GPReport() {
|
||||||
queryKey: ['gp-daily', submittedFromDate, submittedToDate],
|
queryKey: ['gp-daily', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch chart data')
|
if (!res.ok) throw new Error('Failed to fetch chart data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -346,7 +354,7 @@ export default function GPReport() {
|
||||||
queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate],
|
queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/gp/top-sellers?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/gp/top-sellers?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Don't throw for top sellers - just return empty data
|
// Don't throw for top sellers - just return empty data
|
||||||
|
|
@ -554,12 +562,62 @@ export default function GPReport() {
|
||||||
|
|
||||||
// Calculate GP with selected allowances + CD adjustments
|
// Calculate GP with selected allowances + CD adjustments
|
||||||
const gpWithSelectedAllowances = salesNum > 0
|
const gpWithSelectedAllowances = salesNum > 0
|
||||||
? ((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100)
|
? Math.min((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100, 100)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
const downloadCSV = () => {
|
||||||
|
if (!data) return
|
||||||
|
const rows: string[][] = [
|
||||||
|
['Kitchen Flash GP Report'],
|
||||||
|
['Period', period_label],
|
||||||
|
[],
|
||||||
|
['Metric', 'Value'],
|
||||||
|
['Net Food Sales', net_food_sales.toString()],
|
||||||
|
['Net Food Purchases', net_food_purchases.toString()],
|
||||||
|
['Gross Profit', gross_profit.toString()],
|
||||||
|
['Gross Profit %', Number(gross_profit_percent).toFixed(2)],
|
||||||
|
]
|
||||||
|
if (hasAnyAllowancesData) {
|
||||||
|
rows.push([])
|
||||||
|
rows.push(['Adjustments', ''])
|
||||||
|
if (hasWastage) rows.push(['Wastage', (wastage_total ?? 0).toString()])
|
||||||
|
if (hasTransfer) rows.push(['Transfers', (transfer_total ?? 0).toString()])
|
||||||
|
if (hasStaffFood) rows.push(['Staff Food', (staff_food_total ?? 0).toString()])
|
||||||
|
if (hasManualAdjustment) rows.push(['Manual Adjustments', (manual_adjustment_total ?? 0).toString()])
|
||||||
|
if (hasDisputes) rows.push(['Disputes', (disputes_total ?? 0).toString()])
|
||||||
|
rows.push(['GP with Adjustments %', gpWithSelectedAllowances.toFixed(2)])
|
||||||
|
}
|
||||||
|
if (data.supplier_breakdown?.length) {
|
||||||
|
rows.push([])
|
||||||
|
rows.push(['Supplier', 'Net Purchases', '% of Total'])
|
||||||
|
data.supplier_breakdown.forEach(s => {
|
||||||
|
rows.push([s.supplier_name, s.net_purchases.toString(), s.percentage.toFixed(1)])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (chartData?.data?.length) {
|
||||||
|
rows.push([])
|
||||||
|
rows.push(['Date', 'Net Sales', 'Net Purchases', 'Covers'])
|
||||||
|
chartData.data.forEach(d => {
|
||||||
|
rows.push([d.date, d.net_sales.toString(), d.net_purchases.toString(), (d.total_covers ?? '').toString()])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `kitchen-flash-${submittedFromDate}-to-${submittedToDate}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle checkbox handler
|
// Toggle checkbox handler
|
||||||
const toggleAllowance = (key: keyof typeof allowancesSelection) => {
|
const toggleAllowance = (key: keyof typeof allowancesSelection) => {
|
||||||
setAllowancesSelection(prev => ({ ...prev, [key]: !prev[key] }))
|
setAllowancesSelection(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
try { localStorage.setItem('gpreport_allowances_v1', JSON.stringify(next)) } catch {}
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -626,7 +684,14 @@ export default function GPReport() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period Label */}
|
{/* Period Label */}
|
||||||
<div style={styles.periodLabel}>{getPeriodPrefix()}{period_label}</div>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||||
|
<div style={styles.periodLabel}>{getPeriodPrefix()}{period_label}</div>
|
||||||
|
{data && (
|
||||||
|
<button onClick={downloadCSV} style={styles.csvBtn}>
|
||||||
|
Download CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content - GP Estimate and Chart side by side */}
|
{/* Main Content - GP Estimate and Chart side by side */}
|
||||||
<div style={styles.mainContent}>
|
<div style={styles.mainContent}>
|
||||||
|
|
@ -1252,9 +1317,18 @@ const styles: Record<string, React.CSSProperties> = {
|
||||||
fontSize: '1.1rem',
|
fontSize: '1.1rem',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#1a1a2e',
|
color: '#1a1a2e',
|
||||||
marginBottom: '1rem',
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
csvBtn: {
|
||||||
|
padding: '0.4rem 0.9rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #1a1a2e',
|
||||||
|
color: '#1a1a2e',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
},
|
||||||
mainContent: {
|
mainContent: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,396 +1,396 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
import FoodFlagBadges from './FoodFlagBadges'
|
import FoodFlagBadges from './FoodFlagBadges'
|
||||||
import IngredientModal from './IngredientModal'
|
import IngredientModal from './IngredientModal'
|
||||||
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
|
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
|
||||||
|
|
||||||
interface FlagInfo {
|
interface FlagInfo {
|
||||||
id: number
|
id: number
|
||||||
food_flag_id: number
|
food_flag_id: number
|
||||||
flag_name: string
|
flag_name: string
|
||||||
flag_code: string | null
|
flag_code: string | null
|
||||||
category_name: string
|
category_name: string
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IngredientItem {
|
interface IngredientItem {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
category_id: number | null
|
category_id: number | null
|
||||||
category_name: string | null
|
category_name: string | null
|
||||||
standard_unit: string
|
standard_unit: string
|
||||||
yield_percent: number
|
yield_percent: number
|
||||||
manual_price: number | null
|
manual_price: number | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
is_archived: boolean
|
is_archived: boolean
|
||||||
is_prepackaged: boolean
|
is_prepackaged: boolean
|
||||||
is_free: boolean
|
is_free: boolean
|
||||||
product_ingredients: string | null
|
product_ingredients: string | null
|
||||||
has_label_image: boolean
|
has_label_image: boolean
|
||||||
source_count: number
|
source_count: number
|
||||||
effective_price: number | null
|
effective_price: number | null
|
||||||
flags: FlagInfo[]
|
flags: FlagInfo[]
|
||||||
none_categories: string[]
|
none_categories: string[]
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceItem {
|
interface SourceItem {
|
||||||
id: number
|
id: number
|
||||||
supplier_id: number
|
supplier_id: number
|
||||||
supplier_name: string
|
supplier_name: string
|
||||||
product_code: string | null
|
product_code: string | null
|
||||||
description_pattern: string | null
|
description_pattern: string | null
|
||||||
pack_quantity: number | null
|
pack_quantity: number | null
|
||||||
unit_size: number | null
|
unit_size: number | null
|
||||||
unit_size_type: string | null
|
unit_size_type: string | null
|
||||||
latest_unit_price: number | null
|
latest_unit_price: number | null
|
||||||
latest_invoice_date: string | null
|
latest_invoice_date: string | null
|
||||||
price_per_std_unit: number | null
|
price_per_std_unit: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Ingredients() {
|
export default function Ingredients() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
const [showUnmapped, setShowUnmapped] = useState(false)
|
const [showUnmapped, setShowUnmapped] = useState(false)
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
|
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
|
||||||
|
|
||||||
// Categories (for filter dropdown)
|
// Categories (for filter dropdown)
|
||||||
const { data: categories } = useQuery<IngredientCategory[]>({
|
const { data: categories } = useQuery<IngredientCategory[]>({
|
||||||
queryKey: ['ingredient-categories'],
|
queryKey: ['ingredient-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/ingredients/categories', {
|
const res = await fetch('/kitchen/api/ingredients/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch categories')
|
if (!res.ok) throw new Error('Failed to fetch categories')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ingredients list
|
// Ingredients list
|
||||||
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
|
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
|
||||||
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
|
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (categoryFilter) params.set('category_id', categoryFilter)
|
if (categoryFilter) params.set('category_id', categoryFilter)
|
||||||
if (showUnmapped) params.set('unmapped', 'true')
|
if (showUnmapped) params.set('unmapped', 'true')
|
||||||
if (showArchived) params.set('archived', 'true')
|
if (showArchived) params.set('archived', 'true')
|
||||||
const res = await fetch(`/kitchen/api/ingredients?${params}`, {
|
const res = await fetch(`/kitchen/api/ingredients?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch ingredients')
|
if (!res.ok) throw new Error('Failed to fetch ingredients')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sources for expanded ingredient
|
// Sources for expanded ingredient
|
||||||
const { data: sources } = useQuery<SourceItem[]>({
|
const { data: sources } = useQuery<SourceItem[]>({
|
||||||
queryKey: ['ingredient-sources', expandedId],
|
queryKey: ['ingredient-sources', expandedId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, {
|
const res = await fetch(`/kitchen/api/ingredients/${expandedId}/sources`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch sources')
|
if (!res.ok) throw new Error('Failed to fetch sources')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !!expandedId,
|
enabled: !!token && !!expandedId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
|
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to archive')
|
if (!res.ok) throw new Error('Failed to archive')
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const unarchiveMutation = useMutation({
|
const unarchiveMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
|
const res = await fetch(`/kitchen/api/ingredients/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ is_archived: false }),
|
body: JSON.stringify({ is_archived: false }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to unarchive')
|
if (!res.ok) throw new Error('Failed to unarchive')
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const startEdit = (ing: IngredientItem) => {
|
const startEdit = (ing: IngredientItem) => {
|
||||||
setEditingIngredient({
|
setEditingIngredient({
|
||||||
id: ing.id,
|
id: ing.id,
|
||||||
name: ing.name,
|
name: ing.name,
|
||||||
category_id: ing.category_id,
|
category_id: ing.category_id,
|
||||||
standard_unit: ing.standard_unit,
|
standard_unit: ing.standard_unit,
|
||||||
yield_percent: ing.yield_percent,
|
yield_percent: ing.yield_percent,
|
||||||
manual_price: ing.manual_price,
|
manual_price: ing.manual_price,
|
||||||
notes: ing.notes,
|
notes: ing.notes,
|
||||||
is_prepackaged: ing.is_prepackaged,
|
is_prepackaged: ing.is_prepackaged,
|
||||||
is_free: ing.is_free,
|
is_free: ing.is_free,
|
||||||
product_ingredients: ing.product_ingredients,
|
product_ingredients: ing.product_ingredients,
|
||||||
has_label_image: ing.has_label_image,
|
has_label_image: ing.has_label_image,
|
||||||
})
|
})
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h2 style={{ margin: 0 }}>Ingredient Library</h2>
|
<h2 style={{ margin: 0 }}>Ingredient Library</h2>
|
||||||
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
|
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
|
||||||
+ Create Ingredient
|
+ Create Ingredient
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div style={styles.filterBar}>
|
<div style={styles.filterBar}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search ingredients..."
|
placeholder="Search ingredients..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
/>
|
/>
|
||||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
|
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{categories?.map(c => (
|
{categories?.map(c => (
|
||||||
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
|
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label style={styles.checkLabel}>
|
<label style={styles.checkLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showUnmapped}
|
checked={showUnmapped}
|
||||||
onChange={(e) => setShowUnmapped(e.target.checked)}
|
onChange={(e) => setShowUnmapped(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Unmapped only
|
Unmapped only
|
||||||
</label>
|
</label>
|
||||||
<label style={styles.checkLabel}>
|
<label style={styles.checkLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showArchived}
|
checked={showArchived}
|
||||||
onChange={(e) => setShowArchived(e.target.checked)}
|
onChange={(e) => setShowArchived(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Show Archived
|
Show Archived
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div style={styles.statsBar}>
|
<div style={styles.statsBar}>
|
||||||
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
|
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
|
||||||
<span style={{ color: '#888' }}>|</span>
|
<span style={{ color: '#888' }}>|</span>
|
||||||
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
|
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={styles.loading}>Loading ingredients...</div>
|
<div style={styles.loading}>Loading ingredients...</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Name</th>
|
<th style={styles.th}>Name</th>
|
||||||
<th style={styles.th}>Category</th>
|
<th style={styles.th}>Category</th>
|
||||||
<th style={styles.th}>Unit</th>
|
<th style={styles.th}>Unit</th>
|
||||||
<th style={styles.th}>Yield %</th>
|
<th style={styles.th}>Yield %</th>
|
||||||
<th style={styles.th}>Sources</th>
|
<th style={styles.th}>Sources</th>
|
||||||
<th style={styles.th}>Price/Unit</th>
|
<th style={styles.th}>Price/Unit</th>
|
||||||
<th style={styles.th}>Flags</th>
|
<th style={styles.th}>Flags</th>
|
||||||
<th style={styles.th}>Actions</th>
|
<th style={styles.th}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ingredients?.map(ing => (
|
{ingredients?.map(ing => (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
key={ing.id}
|
key={ing.id}
|
||||||
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
|
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
|
||||||
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
|
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
|
||||||
>
|
>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{ fontWeight: 500, ...(ing.is_archived ? { textDecoration: 'line-through' } : {}) }}>{ing.name}</span>
|
<span style={{ fontWeight: 500, ...(ing.is_archived ? { textDecoration: 'line-through' } : {}) }}>{ing.name}</span>
|
||||||
{ing.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>}
|
{ing.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>{ing.category_name || '-'}</td>
|
<td style={styles.td}>{ing.category_name || '-'}</td>
|
||||||
<td style={styles.td}>{ing.standard_unit}</td>
|
<td style={styles.td}>{ing.standard_unit}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
|
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
|
||||||
{ing.yield_percent}%
|
{ing.yield_percent}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
|
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
{ing.source_count}
|
{ing.source_count}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{ing.effective_price != null
|
{ing.effective_price != null
|
||||||
? (() => {
|
? (() => {
|
||||||
const p = ing.effective_price!
|
const p = ing.effective_price!
|
||||||
const u = ing.standard_unit
|
const u = ing.standard_unit
|
||||||
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
|
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
|
||||||
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
|
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
|
||||||
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
|
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
|
||||||
})()
|
})()
|
||||||
: <span style={{ color: '#aaa' }}>-</span>
|
: <span style={{ color: '#aaa' }}>-</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
|
||||||
<FoodFlagBadges flags={ing.flags.map(f => ({
|
<FoodFlagBadges flags={ing.flags.map(f => ({
|
||||||
name: f.flag_name,
|
name: f.flag_name,
|
||||||
code: f.flag_code || undefined,
|
code: f.flag_code || undefined,
|
||||||
category_name: f.category_name,
|
category_name: f.category_name,
|
||||||
propagation: f.propagation_type,
|
propagation: f.propagation_type,
|
||||||
}))} />
|
}))} />
|
||||||
{ing.none_categories?.map(cat => (
|
{ing.none_categories?.map(cat => (
|
||||||
<span
|
<span
|
||||||
key={cat}
|
key={cat}
|
||||||
title={`${cat}: None apply`}
|
title={`${cat}: None apply`}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
padding: '1px 5px',
|
padding: '1px 5px',
|
||||||
background: '#999',
|
background: '#999',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cat.substring(0, 3)}: None
|
{cat.substring(0, 3)}: None
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
|
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
|
||||||
{ing.is_archived ? (
|
{ing.is_archived ? (
|
||||||
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#22c55e' }}>Unarchive</button>
|
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#22c55e' }}>Unarchive</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#e94560' }}>Archive</button>
|
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#e94560' }}>Archive</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedId === ing.id && (
|
{expandedId === ing.id && (
|
||||||
<tr key={`${ing.id}-expanded`}>
|
<tr key={`${ing.id}-expanded`}>
|
||||||
<td colSpan={8} style={styles.expandedTd}>
|
<td colSpan={8} style={styles.expandedTd}>
|
||||||
<div style={styles.sourcesSection}>
|
<div style={styles.sourcesSection}>
|
||||||
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
|
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
|
||||||
{sources && sources.length > 0 ? (
|
{sources && sources.length > 0 ? (
|
||||||
<table style={{ ...styles.table, margin: 0 }}>
|
<table style={{ ...styles.table, margin: 0 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.thSmall}>Supplier</th>
|
<th style={styles.thSmall}>Supplier</th>
|
||||||
<th style={styles.thSmall}>Code/Pattern</th>
|
<th style={styles.thSmall}>Code/Pattern</th>
|
||||||
<th style={styles.thSmall}>Pack</th>
|
<th style={styles.thSmall}>Pack</th>
|
||||||
<th style={styles.thSmall}>Last Price</th>
|
<th style={styles.thSmall}>Last Price</th>
|
||||||
<th style={styles.thSmall}>Price/{ing.standard_unit}</th>
|
<th style={styles.thSmall}>Price/{ing.standard_unit}</th>
|
||||||
<th style={styles.thSmall}>Last Invoice</th>
|
<th style={styles.thSmall}>Last Invoice</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sources.map(s => (
|
{sources.map(s => (
|
||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td style={styles.tdSmall}>{s.supplier_name}</td>
|
<td style={styles.tdSmall}>{s.supplier_name}</td>
|
||||||
<td style={styles.tdSmall}>
|
<td style={styles.tdSmall}>
|
||||||
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
|
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
|
||||||
<a
|
<a
|
||||||
href={`https://www.brake.co.uk/p/${s.product_code}`}
|
href={`https://www.brake.co.uk/p/${s.product_code}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: '#2563eb', textDecoration: 'underline' }}
|
style={{ color: '#2563eb', textDecoration: 'underline' }}
|
||||||
title="View on Brakes website"
|
title="View on Brakes website"
|
||||||
>
|
>
|
||||||
{s.product_code}
|
{s.product_code}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
s.product_code || s.description_pattern || '-'
|
s.product_code || s.description_pattern || '-'
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.tdSmall}>
|
<td style={styles.tdSmall}>
|
||||||
{s.pack_quantity && s.unit_size
|
{s.pack_quantity && s.unit_size
|
||||||
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
|
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
|
||||||
: '-'
|
: '-'
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.tdSmall}>
|
<td style={styles.tdSmall}>
|
||||||
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
|
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.tdSmall}>
|
<td style={styles.tdSmall}>
|
||||||
{s.price_per_std_unit != null ? (() => {
|
{s.price_per_std_unit != null ? (() => {
|
||||||
const p = typeof s.price_per_std_unit === 'string' ? parseFloat(s.price_per_std_unit) : s.price_per_std_unit
|
const p = typeof s.price_per_std_unit === 'string' ? parseFloat(s.price_per_std_unit) : s.price_per_std_unit
|
||||||
const u = ing.standard_unit
|
const u = ing.standard_unit
|
||||||
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
|
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
|
||||||
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
|
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
|
||||||
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
|
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
|
||||||
})() : '-'}
|
})() : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
|
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
|
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IngredientModal
|
<IngredientModal
|
||||||
open={showModal}
|
open={showModal}
|
||||||
onClose={() => { setShowModal(false); setEditingIngredient(null) }}
|
onClose={() => { setShowModal(false); setEditingIngredient(null) }}
|
||||||
editingIngredient={editingIngredient}
|
editingIngredient={editingIngredient}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
|
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
||||||
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' },
|
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' },
|
||||||
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
|
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
|
||||||
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
||||||
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.9rem', color: '#555' },
|
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.9rem', color: '#555' },
|
||||||
statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' },
|
statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
||||||
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
||||||
thSmall: { padding: '0.4rem 0.5rem', textAlign: 'left' as const, borderBottom: '1px solid #e0e0e0', background: '#f5f5f5', fontSize: '0.75rem', fontWeight: 600, color: '#666' },
|
thSmall: { padding: '0.4rem 0.5rem', textAlign: 'left' as const, borderBottom: '1px solid #e0e0e0', background: '#f5f5f5', fontSize: '0.75rem', fontWeight: 600, color: '#666' },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
|
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
|
||||||
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
|
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
|
||||||
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
|
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
|
||||||
sourcesSection: { padding: '0.5rem' },
|
sourcesSection: { padding: '0.5rem' },
|
||||||
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem', marginRight: '0.25rem' },
|
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem', marginRight: '0.25rem' },
|
||||||
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
||||||
loading: { padding: '2rem', textAlign: 'center' as const, color: '#888' },
|
loading: { padding: '2rem', textAlign: 'center' as const, color: '#888' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export default function InvoiceList() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -121,7 +121,7 @@ export default function InvoiceList() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = queryString ? `/kitchen/api/invoices/?${queryString}` : '/kitchen/api/invoices/'
|
const url = queryString ? `/kitchen/api/invoices/?${queryString}` : '/kitchen/api/invoices/'
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch invoices')
|
if (!res.ok) throw new Error('Failed to fetch invoices')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -138,7 +138,7 @@ export default function InvoiceList() {
|
||||||
params.set('limit', '20')
|
params.set('limit', '20')
|
||||||
params.set('sort', 'recent')
|
params.set('sort', 'recent')
|
||||||
const res = await fetch(`/kitchen/api/invoices/?${params}`, {
|
const res = await fetch(`/kitchen/api/invoices/?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch completed invoices')
|
if (!res.ok) throw new Error('Failed to fetch completed invoices')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export default function LineItemHistoryModal({
|
||||||
if (dateTo) params.set('date_to', dateTo)
|
if (dateTo) params.set('date_to', dateTo)
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/search/line-items/history?${params}`, {
|
const res = await fetch(`/kitchen/api/search/line-items/history?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch history')
|
if (!res.ok) throw new Error('Failed to fetch history')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -101,8 +101,8 @@ export default function LineItemHistoryModal({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/search/line-items/acknowledge-price', {
|
const res = await fetch('/kitchen/api/search/line-items/acknowledge-price', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export default function LinkDisputeModal({
|
||||||
queryKey: ['open-disputes', supplierId],
|
queryKey: ['open-disputes', supplierId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/disputes/supplier/${supplierId}/open`, {
|
const res = await fetch(`/kitchen/api/disputes/supplier/${supplierId}/open`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch disputes')
|
if (!res.ok) throw new Error('Failed to fetch disputes')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -80,9 +80,9 @@ export default function LinkDisputeModal({
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/disputes/${selectedDisputeId}/link-credit-note`, {
|
const res = await fetch(`/kitchen/api/disputes/${selectedDisputeId}/link-credit-note`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
credit_note_invoice_id: creditNoteInvoiceId,
|
credit_note_invoice_id: creditNoteInvoiceId,
|
||||||
|
|
|
||||||
|
|
@ -1,461 +1,461 @@
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
import { useDebounce, getDefaultDateRange } from '../utils/searchHelpers'
|
import { useDebounce, getDefaultDateRange } from '../utils/searchHelpers'
|
||||||
|
|
||||||
interface LineItemResult {
|
interface LineItemResult {
|
||||||
product_code: string | null
|
product_code: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
supplier_id: number | null
|
supplier_id: number | null
|
||||||
supplier_name: string | null
|
supplier_name: string | null
|
||||||
unit: string | null
|
unit: string | null
|
||||||
most_recent_price: number | null
|
most_recent_price: number | null
|
||||||
occurrence_count: number
|
occurrence_count: number
|
||||||
most_recent_invoice_id: number
|
most_recent_invoice_id: number
|
||||||
most_recent_date: string | null
|
most_recent_date: string | null
|
||||||
pack_quantity: number | null
|
pack_quantity: number | null
|
||||||
most_recent_line_item_id: number | null
|
most_recent_line_item_id: number | null
|
||||||
most_recent_line_number: number | null
|
most_recent_line_number: number | null
|
||||||
most_recent_raw_content: string | null
|
most_recent_raw_content: string | null
|
||||||
most_recent_pack_quantity: number | null
|
most_recent_pack_quantity: number | null
|
||||||
most_recent_unit_size: number | null
|
most_recent_unit_size: number | null
|
||||||
most_recent_unit_size_type: string | null
|
most_recent_unit_size_type: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
items: LineItemResult[]
|
items: LineItemResult[]
|
||||||
total_count: number
|
total_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapLineItemsModalProps {
|
interface MapLineItemsModalProps {
|
||||||
ingredient: {
|
ingredient: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
standard_unit: string
|
standard_unit: string
|
||||||
yield_percent: number
|
yield_percent: number
|
||||||
effective_price: number | null
|
effective_price: number | null
|
||||||
}
|
}
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unit conversion factors (same as Review.tsx)
|
// Unit conversion factors (same as Review.tsx)
|
||||||
const CONVERSIONS: Record<string, Record<string, number>> = {
|
const CONVERSIONS: Record<string, Record<string, number>> = {
|
||||||
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
|
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
|
||||||
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
|
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
|
||||||
each: { each: 1 },
|
each: { each: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcConversionDisplay(
|
function calcConversionDisplay(
|
||||||
packQty: number, unitSize: number | null, unitSizeType: string,
|
packQty: number, unitSize: number | null, unitSizeType: string,
|
||||||
standardUnit: string, unitPrice: number | null
|
standardUnit: string, unitPrice: number | null
|
||||||
): string {
|
): string {
|
||||||
if (!unitSize || !unitSizeType) return ''
|
if (!unitSize || !unitSizeType) return ''
|
||||||
const conv = CONVERSIONS[unitSizeType]?.[standardUnit]
|
const conv = CONVERSIONS[unitSizeType]?.[standardUnit]
|
||||||
if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : ''
|
if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : ''
|
||||||
const totalStd = packQty * unitSize * conv
|
const totalStd = packQty * unitSize * conv
|
||||||
const pricePerStd = unitPrice ? (unitPrice / totalStd) : null
|
const pricePerStd = unitPrice ? (unitPrice / totalStd) : null
|
||||||
const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : ''
|
const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : ''
|
||||||
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}`
|
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}`
|
||||||
if (pricePerStd) {
|
if (pricePerStd) {
|
||||||
display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}`
|
display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}`
|
||||||
if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)`
|
if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)`
|
||||||
else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)`
|
else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)`
|
||||||
}
|
}
|
||||||
return display
|
return display
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapLineItemsModal({ ingredient, onClose, onSaved }: MapLineItemsModalProps) {
|
export default function MapLineItemsModal({ ingredient, onClose, onSaved }: MapLineItemsModalProps) {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const defaultDates = useMemo(() => getDefaultDateRange(), [])
|
const defaultDates = useMemo(() => getDefaultDateRange(), [])
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [supplierId, setSupplierId] = useState('')
|
const [supplierId, setSupplierId] = useState('')
|
||||||
const [dateFrom] = useState(defaultDates.from)
|
const [dateFrom] = useState(defaultDates.from)
|
||||||
const [dateTo] = useState(defaultDates.to)
|
const [dateTo] = useState(defaultDates.to)
|
||||||
const debouncedSearch = useDebounce(searchInput, 300)
|
const debouncedSearch = useDebounce(searchInput, 300)
|
||||||
|
|
||||||
// Selected item + pack config
|
// Selected item + pack config
|
||||||
const [selectedItem, setSelectedItem] = useState<LineItemResult | null>(null)
|
const [selectedItem, setSelectedItem] = useState<LineItemResult | null>(null)
|
||||||
const [packQty, setPackQty] = useState(1)
|
const [packQty, setPackQty] = useState(1)
|
||||||
const [unitSize, setUnitSize] = useState<string>('')
|
const [unitSize, setUnitSize] = useState<string>('')
|
||||||
const [unitSizeType, setUnitSizeType] = useState(ingredient.standard_unit)
|
const [unitSizeType, setUnitSizeType] = useState(ingredient.standard_unit)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [successMsg, setSuccessMsg] = useState('')
|
const [successMsg, setSuccessMsg] = useState('')
|
||||||
const [previewError, setPreviewError] = useState(false)
|
const [previewError, setPreviewError] = useState(false)
|
||||||
|
|
||||||
// Suppliers
|
// Suppliers
|
||||||
const { data: suppliers } = useQuery<Supplier[]>({
|
const { data: suppliers } = useQuery<Supplier[]>({
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', { headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch('/kitchen/api/suppliers/', { credentials: 'include' })
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.suppliers || data || []
|
return data.suppliers || data || []
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Search results
|
// Search results
|
||||||
const { data: searchData, isLoading } = useQuery<SearchResponse>({
|
const { data: searchData, isLoading } = useQuery<SearchResponse>({
|
||||||
queryKey: ['map-line-items-search', debouncedSearch, supplierId, dateFrom, dateTo],
|
queryKey: ['map-line-items-search', debouncedSearch, supplierId, dateFrom, dateTo],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (debouncedSearch) params.set('q', debouncedSearch)
|
if (debouncedSearch) params.set('q', debouncedSearch)
|
||||||
if (supplierId) params.set('supplier_id', supplierId)
|
if (supplierId) params.set('supplier_id', supplierId)
|
||||||
params.set('date_from', dateFrom)
|
params.set('date_from', dateFrom)
|
||||||
params.set('date_to', dateTo)
|
params.set('date_to', dateTo)
|
||||||
params.set('limit', '50')
|
params.set('limit', '50')
|
||||||
const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
|
const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { items: [], total_count: 0 }
|
if (!res.ok) return { items: [], total_count: 0 }
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: debouncedSearch.length >= 2,
|
enabled: debouncedSearch.length >= 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
// When selecting a line item, auto-fill pack fields from DB values first
|
// When selecting a line item, auto-fill pack fields from DB values first
|
||||||
const handleSelect = (item: LineItemResult) => {
|
const handleSelect = (item: LineItemResult) => {
|
||||||
setSelectedItem(item)
|
setSelectedItem(item)
|
||||||
setPreviewError(false)
|
setPreviewError(false)
|
||||||
setError('')
|
setError('')
|
||||||
setSuccessMsg('')
|
setSuccessMsg('')
|
||||||
|
|
||||||
// Prefer DB-stored pack values from the most recent line item
|
// Prefer DB-stored pack values from the most recent line item
|
||||||
if (item.most_recent_pack_quantity && item.most_recent_unit_size) {
|
if (item.most_recent_pack_quantity && item.most_recent_unit_size) {
|
||||||
setPackQty(item.most_recent_pack_quantity)
|
setPackQty(item.most_recent_pack_quantity)
|
||||||
setUnitSize(Number(item.most_recent_unit_size).toString())
|
setUnitSize(Number(item.most_recent_unit_size).toString())
|
||||||
setUnitSizeType(item.most_recent_unit_size_type || ingredient.standard_unit)
|
setUnitSizeType(item.most_recent_unit_size_type || ingredient.standard_unit)
|
||||||
} else {
|
} else {
|
||||||
// Reset — useEffect regex fallback will attempt to parse from description
|
// Reset — useEffect regex fallback will attempt to parse from description
|
||||||
setPackQty(1)
|
setPackQty(1)
|
||||||
setUnitSize('')
|
setUnitSize('')
|
||||||
setUnitSizeType(ingredient.standard_unit)
|
setUnitSizeType(ingredient.standard_unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversion display
|
// Conversion display
|
||||||
const conversionDisplay = useMemo(() => {
|
const conversionDisplay = useMemo(() => {
|
||||||
const us = parseFloat(unitSize)
|
const us = parseFloat(unitSize)
|
||||||
if (!us || !unitSizeType) return ''
|
if (!us || !unitSizeType) return ''
|
||||||
const price = selectedItem?.most_recent_price != null ? Number(selectedItem.most_recent_price) : null
|
const price = selectedItem?.most_recent_price != null ? Number(selectedItem.most_recent_price) : null
|
||||||
return calcConversionDisplay(packQty, us, unitSizeType, ingredient.standard_unit, price)
|
return calcConversionDisplay(packQty, us, unitSizeType, ingredient.standard_unit, price)
|
||||||
}, [packQty, unitSize, unitSizeType, ingredient.standard_unit, selectedItem?.most_recent_price])
|
}, [packQty, unitSize, unitSizeType, ingredient.standard_unit, selectedItem?.most_recent_price])
|
||||||
|
|
||||||
// Fallback: auto-fill unitSize from description via client-side regex (only if DB had no pack data)
|
// Fallback: auto-fill unitSize from description via client-side regex (only if DB had no pack data)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedItem) return
|
if (!selectedItem) return
|
||||||
// Skip if we already populated from DB values
|
// Skip if we already populated from DB values
|
||||||
if (selectedItem.most_recent_pack_quantity && selectedItem.most_recent_unit_size) return
|
if (selectedItem.most_recent_pack_quantity && selectedItem.most_recent_unit_size) return
|
||||||
|
|
||||||
const desc = selectedItem.description || ''
|
const desc = selectedItem.description || ''
|
||||||
const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i)
|
const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i)
|
||||||
if (packMatch) {
|
if (packMatch) {
|
||||||
setPackQty(parseInt(packMatch[1]))
|
setPackQty(parseInt(packMatch[1]))
|
||||||
setUnitSize(packMatch[2])
|
setUnitSize(packMatch[2])
|
||||||
let ut = packMatch[3].toLowerCase()
|
let ut = packMatch[3].toLowerCase()
|
||||||
if (ut === 'l') ut = 'ltr'
|
if (ut === 'l') ut = 'ltr'
|
||||||
if (ut === 'gm' || ut === 'gms') ut = 'g'
|
if (ut === 'gm' || ut === 'gms') ut = 'g'
|
||||||
setUnitSizeType(ut)
|
setUnitSizeType(ut)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i)
|
const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i)
|
||||||
if (standaloneMatch) {
|
if (standaloneMatch) {
|
||||||
setPackQty(1)
|
setPackQty(1)
|
||||||
setUnitSize(standaloneMatch[1])
|
setUnitSize(standaloneMatch[1])
|
||||||
let ut = standaloneMatch[2].toLowerCase()
|
let ut = standaloneMatch[2].toLowerCase()
|
||||||
if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr'
|
if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr'
|
||||||
if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g'
|
if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g'
|
||||||
if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg'
|
if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg'
|
||||||
setUnitSizeType(ut)
|
setUnitSizeType(ut)
|
||||||
}
|
}
|
||||||
}, [selectedItem])
|
}, [selectedItem])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!selectedItem) return
|
if (!selectedItem) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
setSuccessMsg('')
|
setSuccessMsg('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sourceData: Record<string, unknown> = {
|
const sourceData: Record<string, unknown> = {
|
||||||
supplier_id: selectedItem.supplier_id,
|
supplier_id: selectedItem.supplier_id,
|
||||||
pack_quantity: packQty || 1,
|
pack_quantity: packQty || 1,
|
||||||
unit_size: parseFloat(unitSize) || null,
|
unit_size: parseFloat(unitSize) || null,
|
||||||
unit_size_type: unitSizeType || null,
|
unit_size_type: unitSizeType || null,
|
||||||
apply_to_existing: true,
|
apply_to_existing: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedItem.product_code) {
|
if (selectedItem.product_code) {
|
||||||
sourceData.product_code = selectedItem.product_code
|
sourceData.product_code = selectedItem.product_code
|
||||||
} else if (selectedItem.description) {
|
} else if (selectedItem.description) {
|
||||||
sourceData.description_pattern = selectedItem.description.substring(0, 100).toLowerCase().trim()
|
sourceData.description_pattern = selectedItem.description.substring(0, 100).toLowerCase().trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedItem.most_recent_price) {
|
if (selectedItem.most_recent_price) {
|
||||||
sourceData.latest_unit_price = selectedItem.most_recent_price
|
sourceData.latest_unit_price = selectedItem.most_recent_price
|
||||||
}
|
}
|
||||||
if (selectedItem.most_recent_invoice_id) {
|
if (selectedItem.most_recent_invoice_id) {
|
||||||
sourceData.invoice_id = selectedItem.most_recent_invoice_id
|
sourceData.invoice_id = selectedItem.most_recent_invoice_id
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/ingredients/${ingredient.id}/sources`, {
|
const res = await fetch(`/kitchen/api/ingredients/${ingredient.id}/sources`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(sourceData),
|
body: JSON.stringify(sourceData),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const count = data.matched_line_items
|
const count = data.matched_line_items
|
||||||
setSuccessMsg(`Source created${count ? ` \u2014 ${count} line item${count > 1 ? 's' : ''} mapped` : ''}`)
|
setSuccessMsg(`Source created${count ? ` \u2014 ${count} line item${count > 1 ? 's' : ''} mapped` : ''}`)
|
||||||
setTimeout(() => onSaved(), 1200)
|
setTimeout(() => onSaved(), 1200)
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
setError('This supplier product is already mapped to this ingredient')
|
setError('This supplier product is already mapped to this ingredient')
|
||||||
} else {
|
} else {
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
setError(body.detail || 'Failed to create source')
|
setError(body.detail || 'Failed to create source')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview URL for the selected line item
|
// Preview URL for the selected line item
|
||||||
const previewUrl = selectedItem?.most_recent_invoice_id && selectedItem?.most_recent_line_number != null
|
const previewUrl = selectedItem?.most_recent_invoice_id && selectedItem?.most_recent_line_number != null
|
||||||
? `/kitchen/api/invoices/${selectedItem.most_recent_invoice_id}/line-items/${selectedItem.most_recent_line_number}/preview?token=${token}`
|
? `/kitchen/api/invoices/${selectedItem.most_recent_invoice_id}/line-items/${selectedItem.most_recent_line_number}/preview`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.overlay} onClick={onClose}>
|
<div style={styles.overlay} onClick={onClose}>
|
||||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Map Line Items to "{ingredient.name}"</h3>
|
<h3 style={{ margin: 0, fontSize: '1rem' }}>Map Line Items to "{ingredient.name}"</h3>
|
||||||
<button onClick={onClose} style={styles.closeBtn}>{'\u2715'}</button>
|
<button onClick={onClose} style={styles.closeBtn}>{'\u2715'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context bar */}
|
{/* Context bar */}
|
||||||
<div style={styles.contextBar}>
|
<div style={styles.contextBar}>
|
||||||
<span><strong>{ingredient.name}</strong></span>
|
<span><strong>{ingredient.name}</strong></span>
|
||||||
<span>Unit: {ingredient.standard_unit}</span>
|
<span>Unit: {ingredient.standard_unit}</span>
|
||||||
{ingredient.effective_price != null && (
|
{ingredient.effective_price != null && (
|
||||||
<span>Current: {'\u00a3'}{Number(ingredient.effective_price).toFixed(4)}/{ingredient.standard_unit}</span>
|
<span>Current: {'\u00a3'}{Number(ingredient.effective_price).toFixed(4)}/{ingredient.standard_unit}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.body}>
|
<div style={styles.body}>
|
||||||
{/* Search controls */}
|
{/* Search controls */}
|
||||||
<div style={styles.searchRow}>
|
<div style={styles.searchRow}>
|
||||||
<input
|
<input
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => { setSearchInput(e.target.value); setSelectedItem(null); setError(''); setSuccessMsg('') }}
|
onChange={(e) => { setSearchInput(e.target.value); setSelectedItem(null); setError(''); setSuccessMsg('') }}
|
||||||
style={{ ...styles.input, flex: 2 }}
|
style={{ ...styles.input, flex: 2 }}
|
||||||
placeholder="Search by product code or description..."
|
placeholder="Search by product code or description..."
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={supplierId}
|
value={supplierId}
|
||||||
onChange={(e) => setSupplierId(e.target.value)}
|
onChange={(e) => setSupplierId(e.target.value)}
|
||||||
style={{ ...styles.input, flex: 1, minWidth: '140px' }}
|
style={{ ...styles.input, flex: 1, minWidth: '140px' }}
|
||||||
>
|
>
|
||||||
<option value="">All Suppliers</option>
|
<option value="">All Suppliers</option>
|
||||||
{suppliers?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
{suppliers?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results table */}
|
{/* Results table */}
|
||||||
{debouncedSearch.length >= 2 && (
|
{debouncedSearch.length >= 2 && (
|
||||||
<div style={styles.resultsContainer}>
|
<div style={styles.resultsContainer}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>Searching...</div>
|
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>Searching...</div>
|
||||||
) : !searchData?.items.length ? (
|
) : !searchData?.items.length ? (
|
||||||
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>No line items found</div>
|
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>No line items found</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Description</th>
|
<th style={styles.th}>Description</th>
|
||||||
<th style={styles.th}>Supplier</th>
|
<th style={styles.th}>Supplier</th>
|
||||||
<th style={styles.th}>Price</th>
|
<th style={styles.th}>Price</th>
|
||||||
<th style={styles.th}>#</th>
|
<th style={styles.th}>#</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{searchData.items.map((item, idx) => {
|
{searchData.items.map((item, idx) => {
|
||||||
const isSelected = selectedItem === item
|
const isSelected = selectedItem === item
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${item.product_code || ''}-${item.supplier_id}-${idx}`}
|
key={`${item.product_code || ''}-${item.supplier_id}-${idx}`}
|
||||||
style={{
|
style={{
|
||||||
...styles.tr,
|
...styles.tr,
|
||||||
background: isSelected ? '#e8f5e9' : undefined,
|
background: isSelected ? '#e8f5e9' : undefined,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
>
|
>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{item.product_code && (
|
{item.product_code && (
|
||||||
<span style={{ color: '#888', fontSize: '0.75rem', marginRight: '0.35rem' }}>{item.product_code}</span>
|
<span style={{ color: '#888', fontSize: '0.75rem', marginRight: '0.35rem' }}>{item.product_code}</span>
|
||||||
)}
|
)}
|
||||||
{item.description}
|
{item.description}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>{item.supplier_name || '-'}</td>
|
<td style={styles.td}>{item.supplier_name || '-'}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{item.most_recent_price != null ? `\u00a3${Number(item.most_recent_price).toFixed(2)}` : '-'}
|
{item.most_recent_price != null ? `\u00a3${Number(item.most_recent_price).toFixed(2)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>{item.occurrence_count}</td>
|
<td style={styles.td}>{item.occurrence_count}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OCR bounding box preview */}
|
{/* OCR bounding box preview */}
|
||||||
{selectedItem && previewUrl && !previewError && (
|
{selectedItem && previewUrl && !previewError && (
|
||||||
<div style={styles.previewContainer}>
|
<div style={styles.previewContainer}>
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Line item preview"
|
alt="Line item preview"
|
||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
onError={() => setPreviewError(true)}
|
onError={() => setPreviewError(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pack config - shown when a line item is selected */}
|
{/* Pack config - shown when a line item is selected */}
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div style={styles.packSection}>
|
<div style={styles.packSection}>
|
||||||
<label style={styles.label}>
|
<label style={styles.label}>
|
||||||
How much {ingredient.name} is in "{selectedItem.description}"?
|
How much {ingredient.name} is in "{selectedItem.description}"?
|
||||||
</label>
|
</label>
|
||||||
<div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
|
<div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
|
||||||
{selectedItem.supplier_name}
|
{selectedItem.supplier_name}
|
||||||
{selectedItem.most_recent_price != null ? ` \u2014 \u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : ''}
|
{selectedItem.most_recent_price != null ? ` \u2014 \u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Raw OCR content for context */}
|
{/* Raw OCR content for context */}
|
||||||
{selectedItem.most_recent_raw_content && selectedItem.most_recent_raw_content !== selectedItem.description && (
|
{selectedItem.most_recent_raw_content && selectedItem.most_recent_raw_content !== selectedItem.description && (
|
||||||
<div style={styles.rawContent}>
|
<div style={styles.rawContent}>
|
||||||
{selectedItem.most_recent_raw_content}
|
{selectedItem.most_recent_raw_content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={styles.packRow}>
|
<div style={styles.packRow}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={styles.packLabel}>Contains</div>
|
<div style={styles.packLabel}>Contains</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={unitSize}
|
value={unitSize}
|
||||||
onChange={(e) => setUnitSize(e.target.value)}
|
onChange={(e) => setUnitSize(e.target.value)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="Size"
|
placeholder="Size"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={styles.packLabel}>Unit</div>
|
<div style={styles.packLabel}>Unit</div>
|
||||||
<select
|
<select
|
||||||
value={unitSizeType}
|
value={unitSizeType}
|
||||||
onChange={(e) => setUnitSizeType(e.target.value)}
|
onChange={(e) => setUnitSizeType(e.target.value)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
>
|
>
|
||||||
<option value="each">each</option>
|
<option value="each">each</option>
|
||||||
<option value="g">g</option>
|
<option value="g">g</option>
|
||||||
<option value="kg">kg</option>
|
<option value="kg">kg</option>
|
||||||
<option value="ml">ml</option>
|
<option value="ml">ml</option>
|
||||||
<option value="ltr">ltr</option>
|
<option value="ltr">ltr</option>
|
||||||
<option value="oz">oz</option>
|
<option value="oz">oz</option>
|
||||||
<option value="cl">cl</option>
|
<option value="cl">cl</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={styles.packLabel}>Pack of</div>
|
<div style={styles.packLabel}>Pack of</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={packQty}
|
value={packQty}
|
||||||
onChange={(e) => setPackQty(parseInt(e.target.value) || 1)}
|
onChange={(e) => setPackQty(parseInt(e.target.value) || 1)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={styles.packLabel}>Line Price</div>
|
<div style={styles.packLabel}>Line Price</div>
|
||||||
<div style={{ ...styles.input, background: '#f5f5f5', display: 'flex', alignItems: 'center' }}>
|
<div style={{ ...styles.input, background: '#f5f5f5', display: 'flex', alignItems: 'center' }}>
|
||||||
{selectedItem.most_recent_price != null ? `\u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : '--'}
|
{selectedItem.most_recent_price != null ? `\u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : '--'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{conversionDisplay && (
|
{conversionDisplay && (
|
||||||
<div style={styles.conversionBar}>
|
<div style={styles.conversionBar}>
|
||||||
{conversionDisplay}
|
{conversionDisplay}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && <div style={styles.errorMsg}>{error}</div>}
|
{error && <div style={styles.errorMsg}>{error}</div>}
|
||||||
{successMsg && <div style={styles.successMsg}>{successMsg}</div>}
|
{successMsg && <div style={styles.successMsg}>{successMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={styles.footer}>
|
<div style={styles.footer}>
|
||||||
<button onClick={onClose} style={styles.cancelBtn}>Cancel</button>
|
<button onClick={onClose} style={styles.cancelBtn}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!selectedItem || saving}
|
disabled={!selectedItem || saving}
|
||||||
style={{ ...styles.primaryBtn, opacity: !selectedItem || saving ? 0.5 : 1 }}
|
style={{ ...styles.primaryBtn, opacity: !selectedItem || saving ? 0.5 : 1 }}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save & Map'}
|
{saving ? 'Saving...' : 'Save & Map'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
||||||
modal: { background: 'white', borderRadius: '10px', width: '750px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.2)', display: 'flex', flexDirection: 'column' },
|
modal: { background: 'white', borderRadius: '10px', width: '750px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.2)', display: 'flex', flexDirection: 'column' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
||||||
contextBar: { display: 'flex', gap: '1rem', padding: '0.5rem 1.25rem', background: '#f8f9fa', borderBottom: '1px solid #eee', fontSize: '0.8rem', color: '#555', flexWrap: 'wrap' },
|
contextBar: { display: 'flex', gap: '1rem', padding: '0.5rem 1.25rem', background: '#f8f9fa', borderBottom: '1px solid #eee', fontSize: '0.8rem', color: '#555', flexWrap: 'wrap' },
|
||||||
body: { padding: '1rem 1.25rem', overflow: 'auto', flex: 1 },
|
body: { padding: '1rem 1.25rem', overflow: 'auto', flex: 1 },
|
||||||
footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
|
footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
|
||||||
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
||||||
searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' },
|
searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' },
|
||||||
input: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem', boxSizing: 'border-box' as const },
|
input: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem', boxSizing: 'border-box' as const },
|
||||||
label: { fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.25rem' },
|
label: { fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.25rem' },
|
||||||
resultsContainer: { maxHeight: '220px', overflow: 'auto', border: '1px solid #e0e0e0', borderRadius: '6px', marginBottom: '0.75rem' },
|
resultsContainer: { maxHeight: '220px', overflow: 'auto', border: '1px solid #e0e0e0', borderRadius: '6px', marginBottom: '0.75rem' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' },
|
table: { width: '100%', borderCollapse: 'collapse' },
|
||||||
th: { padding: '0.4rem 0.6rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555', position: 'sticky' as const, top: 0 },
|
th: { padding: '0.4rem 0.6rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555', position: 'sticky' as const, top: 0 },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.4rem 0.6rem', fontSize: '0.8rem' },
|
td: { padding: '0.4rem 0.6rem', fontSize: '0.8rem' },
|
||||||
previewContainer: { marginBottom: '0.75rem', borderRadius: '6px', overflow: 'hidden', border: '2px solid #ffc107', boxShadow: '0 0 8px rgba(255, 193, 7, 0.3)' },
|
previewContainer: { marginBottom: '0.75rem', borderRadius: '6px', overflow: 'hidden', border: '2px solid #ffc107', boxShadow: '0 0 8px rgba(255, 193, 7, 0.3)' },
|
||||||
previewImage: { width: '100%', display: 'block', maxHeight: '80px', objectFit: 'contain' as const, background: '#fff' },
|
previewImage: { width: '100%', display: 'block', maxHeight: '80px', objectFit: 'contain' as const, background: '#fff' },
|
||||||
rawContent: { fontSize: '0.75rem', color: '#999', fontFamily: 'monospace', marginBottom: '0.5rem', padding: '0.35rem 0.5rem', background: '#f0f0f0', borderRadius: '4px', whiteSpace: 'pre-wrap' as const, lineHeight: 1.3 },
|
rawContent: { fontSize: '0.75rem', color: '#999', fontFamily: 'monospace', marginBottom: '0.5rem', padding: '0.35rem 0.5rem', background: '#f0f0f0', borderRadius: '4px', whiteSpace: 'pre-wrap' as const, lineHeight: 1.3 },
|
||||||
packSection: { background: '#f8f9fa', padding: '0.75rem', borderRadius: '6px', marginTop: '0.5rem' },
|
packSection: { background: '#f8f9fa', padding: '0.75rem', borderRadius: '6px', marginTop: '0.5rem' },
|
||||||
packRow: { display: 'flex', gap: '0.5rem', alignItems: 'flex-end' },
|
packRow: { display: 'flex', gap: '0.5rem', alignItems: 'flex-end' },
|
||||||
packLabel: { fontSize: '0.7rem', fontWeight: 600, color: '#888', marginBottom: '0.2rem' },
|
packLabel: { fontSize: '0.7rem', fontWeight: 600, color: '#888', marginBottom: '0.2rem' },
|
||||||
conversionBar: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32', fontWeight: 500 },
|
conversionBar: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32', fontWeight: 500 },
|
||||||
errorMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#fdecea', borderRadius: '6px', fontSize: '0.85rem', color: '#c62828' },
|
errorMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#fdecea', borderRadius: '6px', fontSize: '0.85rem', color: '#c62828' },
|
||||||
successMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32' },
|
successMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32' },
|
||||||
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
||||||
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
|
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export default function MenuEditor() {
|
||||||
queryKey: ['menu', menuId],
|
queryKey: ['menu', menuId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch menu')
|
if (!res.ok) throw new Error('Failed to fetch menu')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -99,7 +99,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update')
|
if (!res.ok) throw new Error('Failed to update')
|
||||||
|
|
@ -111,7 +111,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to add division')
|
if (!res.ok) throw new Error('Failed to add division')
|
||||||
|
|
@ -127,7 +127,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async ({ divId, name }: { divId: number; name: string }) => {
|
mutationFn: async ({ divId, name }: { divId: number; name: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to rename')
|
if (!res.ok) throw new Error('Failed to rename')
|
||||||
|
|
@ -142,7 +142,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (divId: number) => {
|
mutationFn: async (divId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/${divId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete')
|
if (!res.ok) throw new Error('Failed to delete')
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +153,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/reorder`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/divisions/reorder`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to reorder')
|
if (!res.ok) throw new Error('Failed to reorder')
|
||||||
|
|
@ -165,7 +165,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async ({ itemId, data }: { itemId: number; data: Record<string, unknown> }) => {
|
mutationFn: async ({ itemId, data }: { itemId: number; data: Record<string, unknown> }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update')
|
if (!res.ok) throw new Error('Failed to update')
|
||||||
|
|
@ -180,7 +180,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (itemId: number) => {
|
mutationFn: async (itemId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to remove')
|
if (!res.ok) throw new Error('Failed to remove')
|
||||||
},
|
},
|
||||||
|
|
@ -191,7 +191,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async ({ itemId, confirmed_by_name }: { itemId: number; confirmed_by_name: string }) => {
|
mutationFn: async ({ itemId, confirmed_by_name }: { itemId: number; confirmed_by_name: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/republish`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/republish`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ confirmed_by_name }),
|
body: JSON.stringify({ confirmed_by_name }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to republish')
|
if (!res.ok) throw new Error('Failed to republish')
|
||||||
|
|
@ -203,7 +203,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/reorder`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/reorder`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to reorder')
|
if (!res.ok) throw new Error('Failed to reorder')
|
||||||
|
|
@ -215,7 +215,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (body: { confirmed_by_name: string; items: Array<{ id: number; confirmed: boolean }> }) => {
|
mutationFn: async (body: { confirmed_by_name: string; items: Array<{ id: number; confirmed: boolean }> }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/republish-stale`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/republish-stale`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to batch republish')
|
if (!res.ok) throw new Error('Failed to batch republish')
|
||||||
|
|
@ -233,7 +233,7 @@ export default function MenuEditor() {
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to upload image')
|
if (!res.ok) throw new Error('Failed to upload image')
|
||||||
|
|
@ -245,7 +245,7 @@ export default function MenuEditor() {
|
||||||
mutationFn: async (itemId: number) => {
|
mutationFn: async (itemId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/items/${itemId}/image`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete image')
|
if (!res.ok) throw new Error('Failed to delete image')
|
||||||
},
|
},
|
||||||
|
|
@ -471,7 +471,7 @@ export default function MenuEditor() {
|
||||||
{/* Image thumbnail */}
|
{/* Image thumbnail */}
|
||||||
{item.has_image ? (
|
{item.has_image ? (
|
||||||
<img
|
<img
|
||||||
src={`/kitchen/api/menus/${menuId}/items/${item.id}/image?token=${token}`}
|
src={`/kitchen/api/menus/${menuId}/items/${item.id}/image`}
|
||||||
alt=""
|
alt=""
|
||||||
style={styles.thumbnail}
|
style={styles.thumbnail}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ export default function MenuFlagMatrix({ menuId, menuName, onClose }: Props) {
|
||||||
queryKey: ['menu-flag-matrix', menuId],
|
queryKey: ['menu-flag-matrix', menuId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${menuId}/flags`, {
|
const res = await fetch(`/kitchen/api/menus/${menuId}/flags`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) return (
|
if (isLoading) return (
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ export default function MenuList() {
|
||||||
queryKey: ['menus'],
|
queryKey: ['menus'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/menus', {
|
const res = await fetch('/kitchen/api/menus', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch menus')
|
if (!res.ok) throw new Error('Failed to fetch menus')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ export default function MenuList() {
|
||||||
mutationFn: async (data: { name: string; description: string | null; notes: string | null; preset_divisions: boolean }) => {
|
mutationFn: async (data: { name: string; description: string | null; notes: string | null; preset_divisions: boolean }) => {
|
||||||
const res = await fetch('/kitchen/api/menus', {
|
const res = await fetch('/kitchen/api/menus', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -74,7 +74,7 @@ export default function MenuList() {
|
||||||
mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
|
mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${id}`, {
|
const res = await fetch(`/kitchen/api/menus/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ is_active }),
|
body: JSON.stringify({ is_active }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update')
|
if (!res.ok) throw new Error('Failed to update')
|
||||||
|
|
@ -86,7 +86,7 @@ export default function MenuList() {
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${id}`, {
|
const res = await fetch(`/kitchen/api/menus/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete')
|
if (!res.ok) throw new Error('Failed to delete')
|
||||||
},
|
},
|
||||||
|
|
@ -97,7 +97,7 @@ export default function MenuList() {
|
||||||
mutationFn: async ({ id, name }: { id: number; name: string }) => {
|
mutationFn: async ({ id, name }: { id: number; name: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${id}/duplicate`, {
|
const res = await fetch(`/kitchen/api/menus/${id}/duplicate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -118,7 +118,7 @@ export default function MenuList() {
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
const res = await fetch('/kitchen/api/menus/reorder', {
|
const res = await fetch('/kitchen/api/menus/reorder', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to reorder')
|
if (!res.ok) throw new Error('Failed to reorder')
|
||||||
|
|
|
||||||
|
|
@ -1,254 +1,254 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
interface IngredientChange {
|
interface IngredientChange {
|
||||||
summary: string
|
summary: string
|
||||||
date: string | null
|
date: string | null
|
||||||
ingredient_name: string | null
|
ingredient_name: string | null
|
||||||
old_price: number | null
|
old_price: number | null
|
||||||
new_price: number | null
|
new_price: number | null
|
||||||
unit: string | null
|
unit: string | null
|
||||||
cost_impact: number | null
|
cost_impact: number | null
|
||||||
source_invoice_id: number | null
|
source_invoice_id: number | null
|
||||||
source_invoice_number: string | null
|
source_invoice_number: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImpactItem {
|
interface ImpactItem {
|
||||||
recipe_id: number
|
recipe_id: number
|
||||||
recipe_name: string
|
recipe_name: string
|
||||||
recipe_type: string
|
recipe_type: string
|
||||||
output_unit: string
|
output_unit: string
|
||||||
current_cost_per_unit: number | null
|
current_cost_per_unit: number | null
|
||||||
previous_cost_per_unit: number | null
|
previous_cost_per_unit: number | null
|
||||||
cost_change: number | null
|
cost_change: number | null
|
||||||
cost_change_pct: number | null
|
cost_change_pct: number | null
|
||||||
ingredient_changes: IngredientChange[]
|
ingredient_changes: IngredientChange[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatIngredientPrice(price: number, unit: string | null): string {
|
function formatIngredientPrice(price: number, unit: string | null): string {
|
||||||
if (unit === 'g' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/kg`
|
if (unit === 'g' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/kg`
|
||||||
if (unit === 'ml' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/ltr`
|
if (unit === 'ml' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/ltr`
|
||||||
return `\u00A3${price.toFixed(4)}/${unit || '?'}`
|
return `\u00A3${price.toFixed(4)}/${unit || '?'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImpactData {
|
interface ImpactData {
|
||||||
days: number
|
days: number
|
||||||
recipes: ImpactItem[]
|
recipes: ImpactItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PriceImpact() {
|
export default function PriceImpact() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [days, setDays] = useState(14)
|
const [days, setDays] = useState(14)
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ImpactData>({
|
const { data, isLoading } = useQuery<ImpactData>({
|
||||||
queryKey: ['price-impact', days],
|
queryKey: ['price-impact', days],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/recipes/price-impact?days=${days}`, {
|
const res = await fetch(`/kitchen/api/recipes/price-impact?days=${days}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch price impact')
|
if (!res.ok) throw new Error('Failed to fetch price impact')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = data?.recipes.filter(r => typeFilter === 'all' || r.recipe_type === typeFilter) || []
|
const filtered = data?.recipes.filter(r => typeFilter === 'all' || r.recipe_type === typeFilter) || []
|
||||||
const dishCount = data?.recipes.filter(r => r.recipe_type === 'dish').length || 0
|
const dishCount = data?.recipes.filter(r => r.recipe_type === 'dish').length || 0
|
||||||
const componentCount = data?.recipes.filter(r => r.recipe_type === 'component').length || 0
|
const componentCount = data?.recipes.filter(r => r.recipe_type === 'component').length || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h2 style={{ margin: 0 }}>Price Impact Report</h2>
|
<h2 style={{ margin: 0 }}>Price Impact Report</h2>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
<label style={{ fontSize: '0.85rem', color: '#666' }}>Period:</label>
|
<label style={{ fontSize: '0.85rem', color: '#666' }}>Period:</label>
|
||||||
<select value={days} onChange={e => setDays(Number(e.target.value))} style={styles.select}>
|
<select value={days} onChange={e => setDays(Number(e.target.value))} style={styles.select}>
|
||||||
<option value={7}>7 days</option>
|
<option value={7}>7 days</option>
|
||||||
<option value={14}>14 days</option>
|
<option value={14}>14 days</option>
|
||||||
<option value={30}>30 days</option>
|
<option value={30}>30 days</option>
|
||||||
<option value={60}>60 days</option>
|
<option value={60}>60 days</option>
|
||||||
<option value={90}>90 days</option>
|
<option value={90}>90 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <div style={{ padding: '2rem', color: '#888' }}>Loading...</div>}
|
{isLoading && <div style={{ padding: '2rem', color: '#888' }}>Loading...</div>}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
<div style={styles.statsBar}>
|
<div style={styles.statsBar}>
|
||||||
<span>{data.recipes.length} recipes affected</span>
|
<span>{data.recipes.length} recipes affected</span>
|
||||||
<span style={{ color: '#888' }}>|</span>
|
<span style={{ color: '#888' }}>|</span>
|
||||||
<span>{dishCount} dishes</span>
|
<span>{dishCount} dishes</span>
|
||||||
<span style={{ color: '#888' }}>|</span>
|
<span style={{ color: '#888' }}>|</span>
|
||||||
<span>{componentCount} components</span>
|
<span>{componentCount} components</span>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }}>
|
||||||
{(['all', 'dish', 'component'] as const).map(t => (
|
{(['all', 'dish', 'component'] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTypeFilter(t)}
|
onClick={() => setTypeFilter(t)}
|
||||||
style={{
|
style={{
|
||||||
...styles.filterBtn,
|
...styles.filterBtn,
|
||||||
...(typeFilter === t ? styles.filterBtnActive : {}),
|
...(typeFilter === t ? styles.filterBtnActive : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t === 'all' ? 'All' : t === 'dish' ? 'Dishes' : 'Recipes'}
|
{t === 'all' ? 'All' : t === 'dish' ? 'Dishes' : 'Recipes'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
||||||
No recipes affected by ingredient price changes in the last {days} days.
|
No recipes affected by ingredient price changes in the last {days} days.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Recipe</th>
|
<th style={styles.th}>Recipe</th>
|
||||||
<th style={styles.th}>Type</th>
|
<th style={styles.th}>Type</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Previous Cost</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Previous Cost</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Current Cost</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Current Cost</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Change</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Change</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'center' }}>Details</th>
|
<th style={{ ...styles.th, textAlign: 'center' }}>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(r => (
|
{filtered.map(r => (
|
||||||
<>
|
<>
|
||||||
<tr key={r.recipe_id} style={styles.tr}>
|
<tr key={r.recipe_id} style={styles.tr}>
|
||||||
<td style={{ ...styles.td, fontWeight: 500 }}>
|
<td style={{ ...styles.td, fontWeight: 500 }}>
|
||||||
<span
|
<span
|
||||||
style={{ cursor: 'pointer', color: '#e94560' }}
|
style={{ cursor: 'pointer', color: '#e94560' }}
|
||||||
onClick={() => navigate(r.recipe_type === 'dish' ? `/dishes/${r.recipe_id}` : `/recipes/${r.recipe_id}`)}
|
onClick={() => navigate(r.recipe_type === 'dish' ? `/dishes/${r.recipe_id}` : `/recipes/${r.recipe_id}`)}
|
||||||
>
|
>
|
||||||
{r.recipe_name}
|
{r.recipe_name}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: r.recipe_type === 'dish' ? '#e94560' : '#3b82f6',
|
background: r.recipe_type === 'dish' ? '#e94560' : '#3b82f6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '1px 6px',
|
padding: '1px 6px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
{r.recipe_type.toUpperCase()}
|
{r.recipe_type.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
|
||||||
{r.previous_cost_per_unit != null
|
{r.previous_cost_per_unit != null
|
||||||
? `\u00A3${r.previous_cost_per_unit.toFixed(4)}/${r.output_unit}`
|
? `\u00A3${r.previous_cost_per_unit.toFixed(4)}/${r.output_unit}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
|
||||||
{r.current_cost_per_unit != null
|
{r.current_cost_per_unit != null
|
||||||
? `\u00A3${r.current_cost_per_unit.toFixed(4)}/${r.output_unit}`
|
? `\u00A3${r.current_cost_per_unit.toFixed(4)}/${r.output_unit}`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{
|
<td style={{
|
||||||
...styles.td,
|
...styles.td,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: r.cost_change != null ? (r.cost_change > 0 ? '#dc3545' : r.cost_change < 0 ? '#22c55e' : '#888') : '#888',
|
color: r.cost_change != null ? (r.cost_change > 0 ? '#dc3545' : r.cost_change < 0 ? '#22c55e' : '#888') : '#888',
|
||||||
}}>
|
}}>
|
||||||
{r.cost_change != null ? (
|
{r.cost_change != null ? (
|
||||||
<>
|
<>
|
||||||
{r.cost_change > 0 ? '+' : ''}{`\u00A3${r.cost_change.toFixed(4)}`}
|
{r.cost_change > 0 ? '+' : ''}{`\u00A3${r.cost_change.toFixed(4)}`}
|
||||||
{r.cost_change_pct != null && (
|
{r.cost_change_pct != null && (
|
||||||
<span style={{ fontSize: '0.75rem', marginLeft: '4px' }}>
|
<span style={{ fontSize: '0.75rem', marginLeft: '4px' }}>
|
||||||
({r.cost_change_pct > 0 ? '+' : ''}{r.cost_change_pct}%)
|
({r.cost_change_pct > 0 ? '+' : ''}{r.cost_change_pct}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'center' }}>
|
<td style={{ ...styles.td, textAlign: 'center' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedId(expandedId === r.recipe_id ? null : r.recipe_id)}
|
onClick={() => setExpandedId(expandedId === r.recipe_id ? null : r.recipe_id)}
|
||||||
style={styles.detailBtn}
|
style={styles.detailBtn}
|
||||||
>
|
>
|
||||||
{r.ingredient_changes.length} change{r.ingredient_changes.length !== 1 ? 's' : ''}
|
{r.ingredient_changes.length} change{r.ingredient_changes.length !== 1 ? 's' : ''}
|
||||||
{expandedId === r.recipe_id ? ' \u25B4' : ' \u25BE'}
|
{expandedId === r.recipe_id ? ' \u25B4' : ' \u25BE'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedId === r.recipe_id && (
|
{expandedId === r.recipe_id && (
|
||||||
<tr key={`${r.recipe_id}-detail`}>
|
<tr key={`${r.recipe_id}-detail`}>
|
||||||
<td colSpan={6} style={{ padding: '0 0.75rem 0.75rem 2rem', background: '#fafafa' }}>
|
<td colSpan={6} style={{ padding: '0 0.75rem 0.75rem 2rem', background: '#fafafa' }}>
|
||||||
<div style={{ fontSize: '0.8rem', color: '#555' }}>
|
<div style={{ fontSize: '0.8rem', color: '#555' }}>
|
||||||
{r.ingredient_changes.map((c, i) => (
|
{r.ingredient_changes.map((c, i) => (
|
||||||
<div key={i} style={{ padding: '3px 0', borderBottom: i < r.ingredient_changes.length - 1 ? '1px solid #eee' : 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div key={i} style={{ padding: '3px 0', borderBottom: i < r.ingredient_changes.length - 1 ? '1px solid #eee' : 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span>
|
<span>
|
||||||
<span style={{ color: '#888', marginRight: '0.5rem' }}>{c.date}</span>
|
<span style={{ color: '#888', marginRight: '0.5rem' }}>{c.date}</span>
|
||||||
{c.source_invoice_id && (
|
{c.source_invoice_id && (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: 'pointer', color: '#e94560', marginRight: '0.5rem', textDecoration: 'underline' }}
|
style={{ cursor: 'pointer', color: '#e94560', marginRight: '0.5rem', textDecoration: 'underline' }}
|
||||||
onClick={() => navigate(`/invoice/${c.source_invoice_id}`)}
|
onClick={() => navigate(`/invoice/${c.source_invoice_id}`)}
|
||||||
>
|
>
|
||||||
{c.source_invoice_number || `#${c.source_invoice_id}`}
|
{c.source_invoice_number || `#${c.source_invoice_id}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{c.ingredient_name && c.old_price != null && c.new_price != null ? (
|
{c.ingredient_name && c.old_price != null && c.new_price != null ? (
|
||||||
<>
|
<>
|
||||||
{c.ingredient_name} price changed: {formatIngredientPrice(c.old_price, c.unit)} → {formatIngredientPrice(c.new_price, c.unit)}
|
{c.ingredient_name} price changed: {formatIngredientPrice(c.old_price, c.unit)} → {formatIngredientPrice(c.new_price, c.unit)}
|
||||||
</>
|
</>
|
||||||
) : c.ingredient_name && c.new_price != null && c.old_price == null ? (
|
) : c.ingredient_name && c.new_price != null && c.old_price == null ? (
|
||||||
<>
|
<>
|
||||||
{c.ingredient_name} price set: {formatIngredientPrice(c.new_price, c.unit)}
|
{c.ingredient_name} price set: {formatIngredientPrice(c.new_price, c.unit)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
c.summary
|
c.summary
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{c.cost_impact != null && (
|
{c.cost_impact != null && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
color: c.cost_impact > 0 ? '#dc3545' : c.cost_impact < 0 ? '#22c55e' : '#888',
|
color: c.cost_impact > 0 ? '#dc3545' : c.cost_impact < 0 ? '#22c55e' : '#888',
|
||||||
marginLeft: '1rem',
|
marginLeft: '1rem',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{c.cost_impact > 0 ? '+' : ''}{`\u00A3${c.cost_impact.toFixed(4)}`}/{r.output_unit}
|
{c.cost_impact > 0 ? '+' : ''}{`\u00A3${c.cost_impact.toFixed(4)}`}/{r.output_unit}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' },
|
page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
|
||||||
select: { padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem' },
|
select: { padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem' },
|
||||||
statsBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', padding: '0.5rem 0', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#555' },
|
statsBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', padding: '0.5rem 0', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#555' },
|
||||||
filterBtn: { padding: '3px 10px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
|
filterBtn: { padding: '3px 10px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
|
||||||
filterBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' },
|
filterBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
|
||||||
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
|
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
|
||||||
detailBtn: { padding: '2px 8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
|
detailBtn: { padding: '2px 8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,372 +1,372 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
interface Division {
|
interface Division {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlagState {
|
interface FlagState {
|
||||||
food_flag_id: number
|
food_flag_id: number
|
||||||
flag_name: string
|
flag_name: string
|
||||||
flag_code: string | null
|
flag_code: string | null
|
||||||
flag_icon: string | null
|
flag_icon: string | null
|
||||||
category_name: string
|
category_name: string
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
excludable_on_request: boolean
|
excludable_on_request: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuId?: number
|
menuId?: number
|
||||||
menuName?: string
|
menuName?: string
|
||||||
divisions?: Division[]
|
divisions?: Division[]
|
||||||
preSelectedDivisionId?: number
|
preSelectedDivisionId?: number
|
||||||
recipeId?: number // when opened from DishEditor
|
recipeId?: number // when opened from DishEditor
|
||||||
recipeName?: string
|
recipeName?: string
|
||||||
recipeDesc?: string
|
recipeDesc?: string
|
||||||
recipePrice?: number | null
|
recipePrice?: number | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onPublished: () => void
|
onPublished: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublishToMenuModal({
|
export default function PublishToMenuModal({
|
||||||
menuId: propMenuId,
|
menuId: propMenuId,
|
||||||
divisions: propDivisions,
|
divisions: propDivisions,
|
||||||
preSelectedDivisionId,
|
preSelectedDivisionId,
|
||||||
recipeId: propRecipeId,
|
recipeId: propRecipeId,
|
||||||
recipeName,
|
recipeName,
|
||||||
recipeDesc,
|
recipeDesc,
|
||||||
recipePrice,
|
recipePrice,
|
||||||
onClose,
|
onClose,
|
||||||
onPublished,
|
onPublished,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { token, user } = useAuth()
|
const { token, user } = useAuth()
|
||||||
|
|
||||||
const [step, setStep] = useState<'select' | 'confirm'>('select')
|
const [step, setStep] = useState<'select' | 'confirm'>('select')
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(propRecipeId || null)
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(propRecipeId || null)
|
||||||
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(propMenuId || null)
|
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(propMenuId || null)
|
||||||
const [selectedDivId, setSelectedDivId] = useState<number | null>(preSelectedDivisionId || null)
|
const [selectedDivId, setSelectedDivId] = useState<number | null>(preSelectedDivisionId || null)
|
||||||
const [displayName, setDisplayName] = useState(recipeName || '')
|
const [displayName, setDisplayName] = useState(recipeName || '')
|
||||||
const [description, setDescription] = useState(recipeDesc || '')
|
const [description, setDescription] = useState(recipeDesc || '')
|
||||||
const [price, setPrice] = useState(recipePrice ? String(recipePrice) : '')
|
const [price, setPrice] = useState(recipePrice ? String(recipePrice) : '')
|
||||||
const [confirmedBy, setConfirmedBy] = useState(user?.name || '')
|
const [confirmedBy, setConfirmedBy] = useState(user?.name || '')
|
||||||
const [dishSearch, setDishSearch] = useState('')
|
const [dishSearch, setDishSearch] = useState('')
|
||||||
// LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
// LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||||
const [aiDescLoading, setAiDescLoading] = useState(false)
|
const [aiDescLoading, setAiDescLoading] = useState(false)
|
||||||
const { data: llmSettings } = useQuery<{ llm_enabled: boolean; anthropic_api_key_set: boolean }>({
|
const { data: llmSettings } = useQuery<{ llm_enabled: boolean; anthropic_api_key_set: boolean }>({
|
||||||
queryKey: ['settings'],
|
queryKey: ['settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/', { headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch('/kitchen/api/settings/', { credentials: 'include' })
|
||||||
if (!res.ok) return { llm_enabled: false, anthropic_api_key_set: false }
|
if (!res.ok) return { llm_enabled: false, anthropic_api_key_set: false }
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleGenerateDescription = async () => {
|
const handleGenerateDescription = async () => {
|
||||||
const recipeIdToUse = selectedRecipeId || propRecipeId
|
const recipeIdToUse = selectedRecipeId || propRecipeId
|
||||||
if (!recipeIdToUse) return
|
if (!recipeIdToUse) return
|
||||||
setAiDescLoading(true)
|
setAiDescLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/menus/generate-description', {
|
const res = await fetch('/kitchen/api/menus/generate-description', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ recipe_id: recipeIdToUse, recipe_name: displayName || recipeName || '', ingredients: [], allergen_flags: [] }),
|
body: JSON.stringify({ recipe_id: recipeIdToUse, recipe_name: displayName || recipeName || '', ingredients: [], allergen_flags: [] }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.description) setDescription(data.description)
|
if (data.description) setDescription(data.description)
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setAiDescLoading(false)
|
setAiDescLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch dishes list (when opened from MenuEditor, need to pick a dish)
|
// Fetch dishes list (when opened from MenuEditor, need to pick a dish)
|
||||||
const { data: dishes } = useQuery<Array<{ id: number; name: string; description: string | null; gross_sell_price: number | null }>>({
|
const { data: dishes } = useQuery<Array<{ id: number; name: string; description: string | null; gross_sell_price: number | null }>>({
|
||||||
queryKey: ['dishes-for-menu'],
|
queryKey: ['dishes-for-menu'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
|
const res = await fetch('/kitchen/api/recipes?recipe_type=dish', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch dishes')
|
if (!res.ok) throw new Error('Failed to fetch dishes')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !propRecipeId,
|
enabled: !!token && !propRecipeId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch menus list (when opened from DishEditor, need to pick a menu)
|
// Fetch menus list (when opened from DishEditor, need to pick a menu)
|
||||||
const { data: menus } = useQuery<Array<{ id: number; name: string; is_active: boolean }>>({
|
const { data: menus } = useQuery<Array<{ id: number; name: string; is_active: boolean }>>({
|
||||||
queryKey: ['menus-for-publish'],
|
queryKey: ['menus-for-publish'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/menus', {
|
const res = await fetch('/kitchen/api/menus', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch menus')
|
if (!res.ok) throw new Error('Failed to fetch menus')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !propMenuId,
|
enabled: !!token && !propMenuId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch divisions for selected menu (when from DishEditor)
|
// Fetch divisions for selected menu (when from DishEditor)
|
||||||
const { data: menuDetail } = useQuery<{ divisions: Division[] }>({
|
const { data: menuDetail } = useQuery<{ divisions: Division[] }>({
|
||||||
queryKey: ['menu-divisions', selectedMenuId],
|
queryKey: ['menu-divisions', selectedMenuId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}`, {
|
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !!selectedMenuId && !propMenuId,
|
enabled: !!token && !!selectedMenuId && !propMenuId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch recipe flags when a recipe is selected
|
// Fetch recipe flags when a recipe is selected
|
||||||
const { data: flagData, isLoading: flagsLoading } = useQuery<{
|
const { data: flagData, isLoading: flagsLoading } = useQuery<{
|
||||||
flags: FlagState[]
|
flags: FlagState[]
|
||||||
unassessed_ingredients: Array<{ id: number; name: string; category: string }>
|
unassessed_ingredients: Array<{ id: number; name: string; category: string }>
|
||||||
}>({
|
}>({
|
||||||
queryKey: ['recipe-flags-for-publish', selectedRecipeId],
|
queryKey: ['recipe-flags-for-publish', selectedRecipeId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/recipes/${selectedRecipeId}/flags`, {
|
const res = await fetch(`/kitchen/api/food-flags/recipes/${selectedRecipeId}/flags`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !!selectedRecipeId,
|
enabled: !!token && !!selectedRecipeId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const divisions = propDivisions || menuDetail?.divisions || []
|
const divisions = propDivisions || menuDetail?.divisions || []
|
||||||
const activeMenus = (menus || []).filter(m => m.is_active)
|
const activeMenus = (menus || []).filter(m => m.is_active)
|
||||||
const unassessed = flagData?.unassessed_ingredients || []
|
const unassessed = flagData?.unassessed_ingredients || []
|
||||||
const activeFlags = (flagData?.flags || []).filter(f => f.is_active)
|
const activeFlags = (flagData?.flags || []).filter(f => f.is_active)
|
||||||
const hasUnassessed = unassessed.length > 0
|
const hasUnassessed = unassessed.length > 0
|
||||||
|
|
||||||
// When a dish is selected (from dish picker), populate fields
|
// When a dish is selected (from dish picker), populate fields
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRecipeId && dishes) {
|
if (selectedRecipeId && dishes) {
|
||||||
const dish = dishes.find(d => d.id === selectedRecipeId)
|
const dish = dishes.find(d => d.id === selectedRecipeId)
|
||||||
if (dish) {
|
if (dish) {
|
||||||
setDisplayName(dish.name)
|
setDisplayName(dish.name)
|
||||||
setDescription(dish.description || '')
|
setDescription(dish.description || '')
|
||||||
setPrice(dish.gross_sell_price ? String(dish.gross_sell_price) : '')
|
setPrice(dish.gross_sell_price ? String(dish.gross_sell_price) : '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedRecipeId, dishes])
|
}, [selectedRecipeId, dishes])
|
||||||
|
|
||||||
const publishMutation = useMutation({
|
const publishMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}/items`, {
|
const res = await fetch(`/kitchen/api/menus/${selectedMenuId}/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipe_id: selectedRecipeId,
|
recipe_id: selectedRecipeId,
|
||||||
division_id: selectedDivId,
|
division_id: selectedDivId,
|
||||||
display_name: displayName.trim(),
|
display_name: displayName.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
price: price ? parseFloat(price) : null,
|
price: price ? parseFloat(price) : null,
|
||||||
confirmed_by_name: confirmedBy.trim(),
|
confirmed_by_name: confirmedBy.trim(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
throw new Error(typeof err.detail === 'string' ? err.detail : err.detail?.message || 'Failed to publish')
|
throw new Error(typeof err.detail === 'string' ? err.detail : err.detail?.message || 'Failed to publish')
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
onSuccess: () => onPublished(),
|
onSuccess: () => onPublished(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredDishes = (dishes || []).filter(d =>
|
const filteredDishes = (dishes || []).filter(d =>
|
||||||
!dishSearch || d.name.toLowerCase().includes(dishSearch.toLowerCase())
|
!dishSearch || d.name.toLowerCase().includes(dishSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.overlay} onClick={onClose}>
|
<div style={styles.overlay} onClick={onClose}>
|
||||||
<div style={{ ...styles.modal, maxWidth: '550px' }} onClick={(e) => e.stopPropagation()}>
|
<div style={{ ...styles.modal, maxWidth: '550px' }} onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 style={{ margin: '0 0 1rem' }}>
|
<h3 style={{ margin: '0 0 1rem' }}>
|
||||||
{step === 'select' ? 'Publish Dish to Menu' : 'Confirm Allergens & Publish'}
|
{step === 'select' ? 'Publish Dish to Menu' : 'Confirm Allergens & Publish'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<>
|
<>
|
||||||
{/* Dish selection (when from MenuEditor) */}
|
{/* Dish selection (when from MenuEditor) */}
|
||||||
{!propRecipeId && (
|
{!propRecipeId && (
|
||||||
<>
|
<>
|
||||||
<label style={styles.label}>Select Dish *</label>
|
<label style={styles.label}>Select Dish *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={dishSearch}
|
value={dishSearch}
|
||||||
onChange={(e) => setDishSearch(e.target.value)}
|
onChange={(e) => setDishSearch(e.target.value)}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Search dishes..."
|
placeholder="Search dishes..."
|
||||||
/>
|
/>
|
||||||
<div style={{ maxHeight: '200px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.25rem' }}>
|
<div style={{ maxHeight: '200px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.25rem' }}>
|
||||||
{filteredDishes.map(dish => (
|
{filteredDishes.map(dish => (
|
||||||
<div
|
<div
|
||||||
key={dish.id}
|
key={dish.id}
|
||||||
onClick={() => setSelectedRecipeId(dish.id)}
|
onClick={() => setSelectedRecipeId(dish.id)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: selectedRecipeId === dish.id ? '#eff6ff' : '#fff',
|
background: selectedRecipeId === dish.id ? '#eff6ff' : '#fff',
|
||||||
borderBottom: '1px solid #f3f4f6',
|
borderBottom: '1px solid #f3f4f6',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: selectedRecipeId === dish.id ? 600 : 400 }}>{dish.name}</span>
|
<span style={{ fontWeight: selectedRecipeId === dish.id ? 600 : 400 }}>{dish.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredDishes.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>}
|
{filteredDishes.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Menu selection (when from DishEditor) */}
|
{/* Menu selection (when from DishEditor) */}
|
||||||
{!propMenuId && (
|
{!propMenuId && (
|
||||||
<>
|
<>
|
||||||
<label style={styles.label}>Menu *</label>
|
<label style={styles.label}>Menu *</label>
|
||||||
<select
|
<select
|
||||||
value={selectedMenuId || ''}
|
value={selectedMenuId || ''}
|
||||||
onChange={(e) => { setSelectedMenuId(parseInt(e.target.value)); setSelectedDivId(null) }}
|
onChange={(e) => { setSelectedMenuId(parseInt(e.target.value)); setSelectedDivId(null) }}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
>
|
>
|
||||||
<option value="">Select a menu...</option>
|
<option value="">Select a menu...</option>
|
||||||
{activeMenus.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
{activeMenus.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Division selection */}
|
{/* Division selection */}
|
||||||
{divisions.length > 0 && (
|
{divisions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<label style={styles.label}>Section *</label>
|
<label style={styles.label}>Section *</label>
|
||||||
<select
|
<select
|
||||||
value={selectedDivId || ''}
|
value={selectedDivId || ''}
|
||||||
onChange={(e) => setSelectedDivId(parseInt(e.target.value))}
|
onChange={(e) => setSelectedDivId(parseInt(e.target.value))}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
>
|
>
|
||||||
<option value="">Select section...</option>
|
<option value="">Select section...</option>
|
||||||
{divisions.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
|
{divisions.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={styles.modalActions}>
|
<div style={styles.modalActions}>
|
||||||
<button onClick={onClose} style={styles.btn}>Cancel</button>
|
<button onClick={onClose} style={styles.btn}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep('confirm')}
|
onClick={() => setStep('confirm')}
|
||||||
style={styles.btnPrimary}
|
style={styles.btnPrimary}
|
||||||
disabled={!selectedRecipeId || !selectedMenuId || !selectedDivId}
|
disabled={!selectedRecipeId || !selectedMenuId || !selectedDivId}
|
||||||
>
|
>
|
||||||
Next: Review Allergens
|
Next: Review Allergens
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'confirm' && (
|
{step === 'confirm' && (
|
||||||
<>
|
<>
|
||||||
{flagsLoading && <p>Loading allergen data...</p>}
|
{flagsLoading && <p>Loading allergen data...</p>}
|
||||||
|
|
||||||
{!flagsLoading && hasUnassessed && (
|
{!flagsLoading && hasUnassessed && (
|
||||||
<div style={{ background: '#fee2e2', padding: '0.75rem', borderRadius: '6px', marginBottom: '1rem' }}>
|
<div style={{ background: '#fee2e2', padding: '0.75rem', borderRadius: '6px', marginBottom: '1rem' }}>
|
||||||
<strong style={{ color: '#dc2626' }}>Cannot publish: unassessed ingredients</strong>
|
<strong style={{ color: '#dc2626' }}>Cannot publish: unassessed ingredients</strong>
|
||||||
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
|
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
|
||||||
{unassessed.map((u, i) => <li key={i} style={{ fontSize: '0.875rem' }}>{u.name} — {u.category}</li>)}
|
{unassessed.map((u, i) => <li key={i} style={{ fontSize: '0.875rem' }}>{u.name} — {u.category}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!flagsLoading && !hasUnassessed && (
|
{!flagsLoading && !hasUnassessed && (
|
||||||
<>
|
<>
|
||||||
{activeFlags.length > 0 && (
|
{activeFlags.length > 0 && (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
<label style={styles.label}>Confirmed Allergens</label>
|
<label style={styles.label}>Confirmed Allergens</label>
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
{activeFlags.map(f => (
|
{activeFlags.map(f => (
|
||||||
<span key={f.food_flag_id} style={{
|
<span key={f.food_flag_id} style={{
|
||||||
padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem',
|
padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem',
|
||||||
background: f.propagation_type === 'contains' ? '#fee2e2' : '#dcfce7',
|
background: f.propagation_type === 'contains' ? '#fee2e2' : '#dcfce7',
|
||||||
color: f.propagation_type === 'contains' ? '#991b1b' : '#166534',
|
color: f.propagation_type === 'contains' ? '#991b1b' : '#166534',
|
||||||
}}>
|
}}>
|
||||||
{f.flag_code || f.flag_name}
|
{f.flag_code || f.flag_name}
|
||||||
{f.excludable_on_request && ' *'}
|
{f.excludable_on_request && ' *'}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label style={styles.label}>Display Name *</label>
|
<label style={styles.label}>Display Name *</label>
|
||||||
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={styles.input} />
|
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={styles.input} />
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<label style={styles.label}>Description</label>
|
<label style={styles.label}>Description</label>
|
||||||
{/* LLM FEATURE — see LLM-MANIFEST.md for removal instructions */}
|
{/* LLM FEATURE — see LLM-MANIFEST.md for removal instructions */}
|
||||||
{llmSettings?.llm_enabled && llmSettings?.anthropic_api_key_set && (
|
{llmSettings?.llm_enabled && llmSettings?.anthropic_api_key_set && (
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateDescription}
|
onClick={handleGenerateDescription}
|
||||||
disabled={aiDescLoading}
|
disabled={aiDescLoading}
|
||||||
style={{ padding: '0.2rem 0.6rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiDescLoading ? 'wait' : 'pointer', fontSize: '0.75rem', opacity: aiDescLoading ? 0.7 : 1 }}
|
style={{ padding: '0.2rem 0.6rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiDescLoading ? 'wait' : 'pointer', fontSize: '0.75rem', opacity: aiDescLoading ? 0.7 : 1 }}
|
||||||
>
|
>
|
||||||
{aiDescLoading ? 'Generating...' : '\u2728 Generate'}
|
{aiDescLoading ? 'Generating...' : '\u2728 Generate'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }}
|
style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={styles.label}>Price</label>
|
<label style={styles.label}>Price</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
style={{ ...styles.input, width: '120px' }}
|
style={{ ...styles.input, width: '120px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={styles.label}>Confirmed by *</label>
|
<label style={styles.label}>Confirmed by *</label>
|
||||||
<input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} />
|
<input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={styles.modalActions}>
|
<div style={styles.modalActions}>
|
||||||
<button onClick={() => setStep('select')} style={styles.btn}>Back</button>
|
<button onClick={() => setStep('select')} style={styles.btn}>Back</button>
|
||||||
<button onClick={onClose} style={styles.btn}>Cancel</button>
|
<button onClick={onClose} style={styles.btn}>Cancel</button>
|
||||||
{!hasUnassessed && (
|
{!hasUnassessed && (
|
||||||
<button
|
<button
|
||||||
onClick={() => publishMutation.mutate()}
|
onClick={() => publishMutation.mutate()}
|
||||||
style={styles.btnPrimary}
|
style={styles.btnPrimary}
|
||||||
disabled={!displayName.trim() || !confirmedBy.trim() || publishMutation.isPending}
|
disabled={!displayName.trim() || !confirmedBy.trim() || publishMutation.isPending}
|
||||||
>
|
>
|
||||||
{publishMutation.isPending ? 'Publishing...' : 'Publish to Menu'}
|
{publishMutation.isPending ? 'Publishing...' : 'Publish to Menu'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{publishMutation.isError && (
|
{publishMutation.isError && (
|
||||||
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
{(publishMutation.error as Error).message}
|
{(publishMutation.error as Error).message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
||||||
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' },
|
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' },
|
||||||
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
|
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
|
||||||
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
|
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
|
||||||
input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
|
input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
|
||||||
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
|
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
|
||||||
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
|
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,283 +1,283 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
import PurchaseOrderModal from './PurchaseOrderModal'
|
import PurchaseOrderModal from './PurchaseOrderModal'
|
||||||
|
|
||||||
interface PurchaseOrderSummary {
|
interface PurchaseOrderSummary {
|
||||||
id: number
|
id: number
|
||||||
supplier_id: number
|
supplier_id: number
|
||||||
supplier_name: string | null
|
supplier_name: string | null
|
||||||
order_date: string
|
order_date: string
|
||||||
order_type: string
|
order_type: string
|
||||||
status: string
|
status: string
|
||||||
total_amount: number | null
|
total_amount: number | null
|
||||||
order_reference: string | null
|
order_reference: string | null
|
||||||
created_by_name: string | null
|
created_by_name: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, { bg: string; color: string }> = {
|
const statusColors: Record<string, { bg: string; color: string }> = {
|
||||||
DRAFT: { bg: '#e0e0e0', color: '#555' },
|
DRAFT: { bg: '#e0e0e0', color: '#555' },
|
||||||
PENDING: { bg: '#e3f2fd', color: '#1565c0' },
|
PENDING: { bg: '#e3f2fd', color: '#1565c0' },
|
||||||
LINKED: { bg: '#d4edda', color: '#155724' },
|
LINKED: { bg: '#d4edda', color: '#155724' },
|
||||||
CLOSED: { bg: '#f5f5f5', color: '#666' },
|
CLOSED: { bg: '#f5f5f5', color: '#666' },
|
||||||
CANCELLED: { bg: '#ffebee', color: '#c62828' },
|
CANCELLED: { bg: '#ffebee', color: '#c62828' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PurchaseOrderList() {
|
export default function PurchaseOrderList() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const [statusFilter, setStatusFilter] = useState('DRAFT,PENDING')
|
const [statusFilter, setStatusFilter] = useState('DRAFT,PENDING')
|
||||||
const [supplierFilter, setSupplierFilter] = useState('')
|
const [supplierFilter, setSupplierFilter] = useState('')
|
||||||
const [editPoId, setEditPoId] = useState<number | null>(null)
|
const [editPoId, setEditPoId] = useState<number | null>(null)
|
||||||
const [showNewModal, setShowNewModal] = useState(false)
|
const [showNewModal, setShowNewModal] = useState(false)
|
||||||
|
|
||||||
// Fetch suppliers for filter
|
// Fetch suppliers for filter
|
||||||
const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({
|
const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build query string
|
// Build query string
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (statusFilter) params.append('status', statusFilter)
|
if (statusFilter) params.append('status', statusFilter)
|
||||||
if (supplierFilter) params.append('supplier_id', supplierFilter)
|
if (supplierFilter) params.append('supplier_id', supplierFilter)
|
||||||
|
|
||||||
const { data: poList, refetch } = useQuery<PurchaseOrderSummary[]>({
|
const { data: poList, refetch } = useQuery<PurchaseOrderSummary[]>({
|
||||||
queryKey: ['purchase-orders', statusFilter, supplierFilter],
|
queryKey: ['purchase-orders', statusFilter, supplierFilter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/purchase-orders/?${params}`, {
|
const res = await fetch(`/kitchen/api/purchase-orders/?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch purchase orders')
|
if (!res.ok) throw new Error('Failed to fetch purchase orders')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (d: string) => {
|
const formatDate = (d: string) => {
|
||||||
const dt = new Date(d + 'T00:00:00')
|
const dt = new Date(d + 'T00:00:00')
|
||||||
return dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
return dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={styles.headerRow}>
|
<div style={styles.headerRow}>
|
||||||
<h2 style={styles.pageTitle}>Purchase Orders</h2>
|
<h2 style={styles.pageTitle}>Purchase Orders</h2>
|
||||||
<button style={styles.newBtn} onClick={() => setShowNewModal(true)}>+ New PO</button>
|
<button style={styles.newBtn} onClick={() => setShowNewModal(true)}>+ New PO</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div style={styles.filterBar}>
|
<div style={styles.filterBar}>
|
||||||
<div style={styles.statusTabs}>
|
<div style={styles.statusTabs}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Open', value: 'DRAFT,PENDING' },
|
{ label: 'Open', value: 'DRAFT,PENDING' },
|
||||||
{ label: 'All', value: '' },
|
{ label: 'All', value: '' },
|
||||||
{ label: 'Draft', value: 'DRAFT' },
|
{ label: 'Draft', value: 'DRAFT' },
|
||||||
{ label: 'Pending', value: 'PENDING' },
|
{ label: 'Pending', value: 'PENDING' },
|
||||||
{ label: 'Linked', value: 'LINKED' },
|
{ label: 'Linked', value: 'LINKED' },
|
||||||
{ label: 'Closed', value: 'CLOSED' },
|
{ label: 'Closed', value: 'CLOSED' },
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
style={{
|
style={{
|
||||||
...styles.filterTab,
|
...styles.filterTab,
|
||||||
...(statusFilter === tab.value ? styles.filterTabActive : {}),
|
...(statusFilter === tab.value ? styles.filterTabActive : {}),
|
||||||
}}
|
}}
|
||||||
onClick={() => setStatusFilter(tab.value)}
|
onClick={() => setStatusFilter(tab.value)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={supplierFilter}
|
value={supplierFilter}
|
||||||
onChange={e => setSupplierFilter(e.target.value)}
|
onChange={e => setSupplierFilter(e.target.value)}
|
||||||
style={styles.filterSelect}
|
style={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<option value="">All Suppliers</option>
|
<option value="">All Suppliers</option>
|
||||||
{suppliersData?.suppliers?.map(s => (
|
{suppliersData?.suppliers?.map(s => (
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div style={styles.tableContainer}>
|
<div style={styles.tableContainer}>
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Date</th>
|
<th style={styles.th}>Date</th>
|
||||||
<th style={styles.th}>Supplier</th>
|
<th style={styles.th}>Supplier</th>
|
||||||
<th style={styles.th}>Type</th>
|
<th style={styles.th}>Type</th>
|
||||||
<th style={styles.th}>Reference</th>
|
<th style={styles.th}>Reference</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Total</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Total</th>
|
||||||
<th style={styles.th}>Status</th>
|
<th style={styles.th}>Status</th>
|
||||||
<th style={styles.th}>Created</th>
|
<th style={styles.th}>Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(!poList || poList.length === 0) ? (
|
{(!poList || poList.length === 0) ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} style={styles.emptyTd}>No purchase orders found</td>
|
<td colSpan={7} style={styles.emptyTd}>No purchase orders found</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
poList.map(po => {
|
poList.map(po => {
|
||||||
const sc = statusColors[po.status] || statusColors.DRAFT
|
const sc = statusColors[po.status] || statusColors.DRAFT
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={po.id}
|
key={po.id}
|
||||||
style={styles.row}
|
style={styles.row}
|
||||||
onClick={() => setEditPoId(po.id)}
|
onClick={() => setEditPoId(po.id)}
|
||||||
>
|
>
|
||||||
<td style={styles.td}>{formatDate(po.order_date)}</td>
|
<td style={styles.td}>{formatDate(po.order_date)}</td>
|
||||||
<td style={styles.td}>{po.supplier_name || '-'}</td>
|
<td style={styles.td}>{po.supplier_name || '-'}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={styles.typeBadge}>
|
<span style={styles.typeBadge}>
|
||||||
{po.order_type === 'itemised' ? 'Itemised' : 'Single Value'}
|
{po.order_type === 'itemised' ? 'Itemised' : 'Single Value'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>{po.order_reference || '-'}</td>
|
<td style={styles.td}>{po.order_reference || '-'}</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 500 }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 500 }}>
|
||||||
{po.total_amount != null ? `\u00A3${po.total_amount.toFixed(2)}` : '-'}
|
{po.total_amount != null ? `\u00A3${po.total_amount.toFixed(2)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{
|
<span style={{
|
||||||
...styles.statusBadge,
|
...styles.statusBadge,
|
||||||
background: sc.bg,
|
background: sc.bg,
|
||||||
color: sc.color,
|
color: sc.color,
|
||||||
}}>
|
}}>
|
||||||
{po.status}
|
{po.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, fontSize: '0.8rem', color: '#888' }}>
|
<td style={{ ...styles.td, fontSize: '0.8rem', color: '#888' }}>
|
||||||
{po.created_by_name || ''}
|
{po.created_by_name || ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<PurchaseOrderModal
|
<PurchaseOrderModal
|
||||||
isOpen={!!editPoId}
|
isOpen={!!editPoId}
|
||||||
onClose={() => setEditPoId(null)}
|
onClose={() => setEditPoId(null)}
|
||||||
onSaved={() => refetch()}
|
onSaved={() => refetch()}
|
||||||
poId={editPoId}
|
poId={editPoId}
|
||||||
/>
|
/>
|
||||||
<PurchaseOrderModal
|
<PurchaseOrderModal
|
||||||
isOpen={showNewModal}
|
isOpen={showNewModal}
|
||||||
onClose={() => setShowNewModal(false)}
|
onClose={() => setShowNewModal(false)}
|
||||||
onSaved={() => refetch()}
|
onSaved={() => refetch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
headerRow: {
|
headerRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: '1.5rem',
|
marginBottom: '1.5rem',
|
||||||
},
|
},
|
||||||
pageTitle: {
|
pageTitle: {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
color: '#1a1a2e',
|
color: '#1a1a2e',
|
||||||
},
|
},
|
||||||
newBtn: {
|
newBtn: {
|
||||||
padding: '0.6rem 1.2rem',
|
padding: '0.6rem 1.2rem',
|
||||||
background: '#1a1a2e',
|
background: '#1a1a2e',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
},
|
},
|
||||||
filterBar: {
|
filterBar: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
statusTabs: {
|
statusTabs: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '0.25rem',
|
gap: '0.25rem',
|
||||||
},
|
},
|
||||||
filterTab: {
|
filterTab: {
|
||||||
padding: '0.4rem 0.75rem',
|
padding: '0.4rem 0.75rem',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
background: 'white',
|
background: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
filterTabActive: {
|
filterTabActive: {
|
||||||
background: '#1a1a2e',
|
background: '#1a1a2e',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
borderColor: '#1a1a2e',
|
borderColor: '#1a1a2e',
|
||||||
},
|
},
|
||||||
filterSelect: {
|
filterSelect: {
|
||||||
padding: '0.4rem 0.75rem',
|
padding: '0.4rem 0.75rem',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
},
|
},
|
||||||
tableContainer: {
|
tableContainer: {
|
||||||
background: 'white',
|
background: 'white',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
},
|
},
|
||||||
th: {
|
th: {
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
borderBottom: '2px solid #eee',
|
borderBottom: '2px solid #eee',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
td: {
|
td: {
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
borderBottom: '1px solid #f0f0f0',
|
borderBottom: '1px solid #f0f0f0',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
},
|
},
|
||||||
emptyTd: {
|
emptyTd: {
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#999',
|
color: '#999',
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
},
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '0.2rem 0.6rem',
|
padding: '0.2rem 0.6rem',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
typeBadge: {
|
typeBadge: {
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -282,7 +282,7 @@ export default function Purchases() {
|
||||||
queryKey: ['purchases-range', submittedFromDate, submittedToDate],
|
queryKey: ['purchases-range', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/purchases/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/purchases/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch purchases')
|
if (!res.ok) throw new Error('Failed to fetch purchases')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -294,7 +294,7 @@ export default function Purchases() {
|
||||||
queryKey: ['daily-dispute-stats', submittedFromDate, submittedToDate],
|
queryKey: ['daily-dispute-stats', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/disputes/stats/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/disputes/stats/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch dispute stats')
|
if (!res.ok) throw new Error('Failed to fetch dispute stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -306,7 +306,7 @@ export default function Purchases() {
|
||||||
queryKey: ['daily-allowance-stats', submittedFromDate, submittedToDate],
|
queryKey: ['daily-allowance-stats', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/logbook/daily-stats?date_from=${submittedFromDate}&date_to=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/logbook/daily-stats?date_from=${submittedFromDate}&date_to=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch allowance stats')
|
if (!res.ok) throw new Error('Failed to fetch allowance stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -380,7 +380,7 @@ export default function Purchases() {
|
||||||
queryKey: ['weekly-chart-data', weeklyChartDateRange.from, weeklyChartDateRange.to],
|
queryKey: ['weekly-chart-data', weeklyChartDateRange.from, weeklyChartDateRange.to],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${weeklyChartDateRange.from}&to_date=${weeklyChartDateRange.to}`, {
|
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${weeklyChartDateRange.from}&to_date=${weeklyChartDateRange.to}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch weekly chart data')
|
if (!res.ok) throw new Error('Failed to fetch weekly chart data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@ export default function PurchasesReport() {
|
||||||
queryKey: ['purchases-summary', submittedFromDate, submittedToDate],
|
queryKey: ['purchases-summary', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/purchases/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/purchases/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch purchases summary')
|
if (!res.ok) throw new Error('Failed to fetch purchases summary')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -285,7 +285,7 @@ export default function PurchasesReport() {
|
||||||
queryKey: ['purchases-daily-supplier', submittedFromDate, submittedToDate],
|
queryKey: ['purchases-daily-supplier', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/purchases/daily-by-supplier?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/reports/purchases/daily-by-supplier?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch chart data')
|
if (!res.ok) throw new Error('Failed to fetch chart data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -303,7 +303,7 @@ export default function PurchasesReport() {
|
||||||
url += `&supplier_id=${topItemsSupplierFilter}`
|
url += `&supplier_id=${topItemsSupplierFilter}`
|
||||||
}
|
}
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch top items')
|
if (!res.ok) throw new Error('Failed to fetch top items')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,333 +1,333 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
interface MatrixCell {
|
interface MatrixCell {
|
||||||
has_flag: boolean
|
has_flag: boolean
|
||||||
is_unassessed: boolean
|
is_unassessed: boolean
|
||||||
is_none: boolean
|
is_none: boolean
|
||||||
has_open_suggestion?: boolean
|
has_open_suggestion?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MatrixIngredient {
|
interface MatrixIngredient {
|
||||||
ingredient_id: number
|
ingredient_id: number
|
||||||
ingredient_name: string
|
ingredient_name: string
|
||||||
is_sub_recipe: boolean
|
is_sub_recipe: boolean
|
||||||
sub_recipe_name: string | null
|
sub_recipe_name: string | null
|
||||||
flags: Record<number, MatrixCell>
|
flags: Record<number, MatrixCell>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlagColumn {
|
interface FlagColumn {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
code: string | null
|
code: string | null
|
||||||
category_id: number
|
category_id: number
|
||||||
category_name: string
|
category_name: string
|
||||||
propagation_type: string
|
propagation_type: string
|
||||||
required: boolean
|
required: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MatrixData {
|
interface MatrixData {
|
||||||
flags: FlagColumn[]
|
flags: FlagColumn[]
|
||||||
ingredients: MatrixIngredient[]
|
ingredients: MatrixIngredient[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recipeId: number
|
recipeId: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
|
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
|
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const { data: rawData, isLoading } = useQuery<MatrixData>({
|
const { data: rawData, isLoading } = useQuery<MatrixData>({
|
||||||
queryKey: ['recipe-flag-matrix', recipeId],
|
queryKey: ['recipe-flag-matrix', recipeId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
|
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch matrix')
|
if (!res.ok) throw new Error('Failed to fetch matrix')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !!recipeId,
|
enabled: !!token && !!recipeId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter to specific category if provided
|
// Filter to specific category if provided
|
||||||
const data = rawData ? {
|
const data = rawData ? {
|
||||||
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
|
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
|
||||||
ingredients: rawData.ingredients,
|
ingredients: rawData.ingredients,
|
||||||
} : undefined
|
} : undefined
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
|
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
|
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
|
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update flag')
|
if (!res.ok) throw new Error('Failed to update flag')
|
||||||
},
|
},
|
||||||
onMutate: ({ ingredientId, flagId }) => {
|
onMutate: ({ ingredientId, flagId }) => {
|
||||||
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
|
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
|
||||||
},
|
},
|
||||||
onSettled: (_data, _err, { ingredientId, flagId }) => {
|
onSettled: (_data, _err, { ingredientId, flagId }) => {
|
||||||
setPendingCells(prev => {
|
setPendingCells(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(`${ingredientId}-${flagId}`)
|
next.delete(`${ingredientId}-${flagId}`)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
|
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
|
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleNoneMutation = useMutation({
|
const toggleNoneMutation = useMutation({
|
||||||
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
|
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
|
const res = await fetch(`/kitchen/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
|
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to toggle none')
|
if (!res.ok) throw new Error('Failed to toggle none')
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
|
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
|
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) return <div style={{ padding: '1rem', color: '#888' }}>Loading matrix...</div>
|
if (isLoading) return <div style={{ padding: '1rem', color: '#888' }}>Loading matrix...</div>
|
||||||
if (!data || !data.flags.length) return <div style={{ padding: '1rem', color: '#888' }}>No flags configured for this category</div>
|
if (!data || !data.flags.length) return <div style={{ padding: '1rem', color: '#888' }}>No flags configured for this category</div>
|
||||||
|
|
||||||
// Group flags by category
|
// Group flags by category
|
||||||
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
|
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
|
||||||
for (const f of data.flags) {
|
for (const f of data.flags) {
|
||||||
if (!categories[f.category_id]) {
|
if (!categories[f.category_id]) {
|
||||||
categories[f.category_id] = { name: f.category_name, propagation: f.propagation_type, required: f.required, flags: [] }
|
categories[f.category_id] = { name: f.category_name, propagation: f.propagation_type, required: f.required, flags: [] }
|
||||||
}
|
}
|
||||||
categories[f.category_id].flags.push(f)
|
categories[f.category_id].flags.push(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group ingredients by sub-recipe
|
// Group ingredients by sub-recipe
|
||||||
let currentSubRecipe = ''
|
let currentSubRecipe = ''
|
||||||
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
|
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
|
||||||
for (const ing of data.ingredients) {
|
for (const ing of data.ingredients) {
|
||||||
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
|
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
|
||||||
currentSubRecipe = ing.sub_recipe_name || ''
|
currentSubRecipe = ing.sub_recipe_name || ''
|
||||||
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
|
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
|
||||||
} else if (!ing.is_sub_recipe && currentSubRecipe) {
|
} else if (!ing.is_sub_recipe && currentSubRecipe) {
|
||||||
currentSubRecipe = ''
|
currentSubRecipe = ''
|
||||||
}
|
}
|
||||||
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
|
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute totals per flag
|
// Compute totals per flag
|
||||||
const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
|
const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
|
||||||
for (const f of data.flags) {
|
for (const f of data.flags) {
|
||||||
const cat = categories[f.category_id]
|
const cat = categories[f.category_id]
|
||||||
if (cat.propagation === 'contains') {
|
if (cat.propagation === 'contains') {
|
||||||
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
|
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
|
||||||
totals[f.id] = { has: anyHas, unassessed: false }
|
totals[f.id] = { has: anyHas, unassessed: false }
|
||||||
} else {
|
} else {
|
||||||
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
|
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
|
||||||
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
|
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
|
||||||
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
|
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if "none" is set per ingredient per category
|
// Check if "none" is set per ingredient per category
|
||||||
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
|
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
|
||||||
const catFlags = categories[catId]?.flags || []
|
const catFlags = categories[catId]?.flags || []
|
||||||
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
|
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
|
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
|
||||||
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
|
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
|
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
|
||||||
const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
|
const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
|
||||||
const hasFlag = cell?.has_flag ?? false
|
const hasFlag = cell?.has_flag ?? false
|
||||||
const isUnassessed = cell?.is_unassessed ?? false
|
const isUnassessed = cell?.is_unassessed ?? false
|
||||||
const isNone = cell?.is_none ?? false
|
const isNone = cell?.is_none ?? false
|
||||||
const hasOpenSuggestion = cell?.has_open_suggestion ?? false
|
const hasOpenSuggestion = cell?.has_open_suggestion ?? false
|
||||||
|
|
||||||
const cellStyle: React.CSSProperties = {
|
const cellStyle: React.CSSProperties = {
|
||||||
...styles.cell,
|
...styles.cell,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
opacity: isPending ? 0.5 : 1,
|
opacity: isPending ? 0.5 : 1,
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small amber dot for open suggestions (not already flagged or unassessed)
|
// Small amber dot for open suggestions (not already flagged or unassessed)
|
||||||
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
|
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', top: '2px', right: '2px',
|
position: 'absolute', top: '2px', right: '2px',
|
||||||
width: '6px', height: '6px', borderRadius: '50%',
|
width: '6px', height: '6px', borderRadius: '50%',
|
||||||
background: '#f59e0b',
|
background: '#f59e0b',
|
||||||
}} title="Unreviewed allergen suggestion" />
|
}} title="Unreviewed allergen suggestion" />
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isPending) return
|
if (isPending) return
|
||||||
if (isUnassessed) {
|
if (isUnassessed) {
|
||||||
handleToggle(ingredientId, flagId, false)
|
handleToggle(ingredientId, flagId, false)
|
||||||
} else {
|
} else {
|
||||||
handleToggle(ingredientId, flagId, hasFlag)
|
handleToggle(ingredientId, flagId, hasFlag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnassessed) {
|
if (isUnassessed) {
|
||||||
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
|
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
|
||||||
}
|
}
|
||||||
if (isNone) {
|
if (isNone) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
|
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
|
||||||
title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
|
title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{'\u2014'}{suggestionDot}
|
{'\u2014'}{suggestionDot}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (propagation === 'contains') {
|
if (propagation === 'contains') {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
style={{ ...cellStyle, color: hasFlag ? '#dc3545' : '#ccc', background: hasFlag ? '#fef2f2' : hasOpenSuggestion ? '#fffbeb' : undefined }}
|
style={{ ...cellStyle, color: hasFlag ? '#dc3545' : '#ccc', background: hasFlag ? '#fef2f2' : hasOpenSuggestion ? '#fffbeb' : undefined }}
|
||||||
title={hasFlag ? `Contains ${flagName || ''} \u2014 click to remove` : hasOpenSuggestion ? `Unreviewed suggestion: ${flagName || ''} \u2014 click to set` : `Click to mark as contains ${flagName || ''}`}
|
title={hasFlag ? `Contains ${flagName || ''} \u2014 click to remove` : hasOpenSuggestion ? `Unreviewed suggestion: ${flagName || ''} \u2014 click to set` : `Click to mark as contains ${flagName || ''}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
|
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
|
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
|
||||||
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
|
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
|
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNoneColumn = Object.values(categories).some(c => c.required)
|
const showNoneColumn = Object.values(categories).some(c => c.required)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
<span style={{ fontSize: '0.75rem', color: '#888' }}>
|
<span style={{ fontSize: '0.75rem', color: '#888' }}>
|
||||||
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
|
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
|
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
|
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
|
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
|
||||||
{Object.entries(categories).map(([catId, cat]) => (
|
{Object.entries(categories).map(([catId, cat]) => (
|
||||||
<>
|
<>
|
||||||
{cat.flags.map(f => (
|
{cat.flags.map(f => (
|
||||||
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
|
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
|
||||||
{Object.keys(categories).length > 1 && (
|
{Object.keys(categories).length > 1 && (
|
||||||
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
|
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
|
||||||
)}
|
)}
|
||||||
<div>{f.code || f.name.substring(0, 4)}</div>
|
<div>{f.code || f.name.substring(0, 4)}</div>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{cat.required && (
|
{cat.required && (
|
||||||
<th key={`none-${catId}`} style={{ ...styles.th, borderLeft: '2px solid #e0e0e0', fontSize: '0.6rem', minWidth: '28px' }} title={`None apply for ${cat.name}`}>
|
<th key={`none-${catId}`} style={{ ...styles.th, borderLeft: '2px solid #e0e0e0', fontSize: '0.6rem', minWidth: '28px' }} title={`None apply for ${cat.name}`}>
|
||||||
<div>N</div>
|
<div>N</div>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, idx) => {
|
{rows.map((row, idx) => {
|
||||||
if (row.type === 'header') {
|
if (row.type === 'header') {
|
||||||
const noneColCount = Object.values(categories).filter(c => c.required).length
|
const noneColCount = Object.values(categories).filter(c => c.required).length
|
||||||
const totalCols = data.flags.length + noneColCount + 1
|
const totalCols = data.flags.length + noneColCount + 1
|
||||||
return (
|
return (
|
||||||
<tr key={`h-${idx}`}>
|
<tr key={`h-${idx}`}>
|
||||||
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
|
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const ing = row.ingredient!
|
const ing = row.ingredient!
|
||||||
return (
|
return (
|
||||||
<tr key={ing.ingredient_id + '-' + idx}>
|
<tr key={ing.ingredient_id + '-' + idx}>
|
||||||
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
|
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
|
||||||
{Object.entries(categories).map(([catId, cat]) => (
|
{Object.entries(categories).map(([catId, cat]) => (
|
||||||
<>
|
<>
|
||||||
{cat.flags.map(f => renderCell(ing.ingredient_id, f.id, ing.flags[f.id], cat.propagation, f.name))}
|
{cat.flags.map(f => renderCell(ing.ingredient_id, f.id, ing.flags[f.id], cat.propagation, f.name))}
|
||||||
{cat.required && (
|
{cat.required && (
|
||||||
<td
|
<td
|
||||||
key={`none-${catId}-${ing.ingredient_id}`}
|
key={`none-${catId}-${ing.ingredient_id}`}
|
||||||
style={{
|
style={{
|
||||||
...styles.cell,
|
...styles.cell,
|
||||||
borderLeft: '2px solid #e0e0e0',
|
borderLeft: '2px solid #e0e0e0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
|
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
|
||||||
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
|
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
|
||||||
fontWeight: getNoneForCategory(ing, Number(catId)) ? 600 : 400,
|
fontWeight: getNoneForCategory(ing, Number(catId)) ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
title={getNoneForCategory(ing, Number(catId)) ? `None apply \u2014 click to unset` : `Click to mark "None apply" for ${cat.name}`}
|
title={getNoneForCategory(ing, Number(catId)) ? `None apply \u2014 click to unset` : `Click to mark "None apply" for ${cat.name}`}
|
||||||
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
|
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
|
||||||
>
|
>
|
||||||
N
|
N
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* Total row */}
|
{/* Total row */}
|
||||||
<tr style={{ borderTop: '3px double #333' }}>
|
<tr style={{ borderTop: '3px double #333' }}>
|
||||||
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
|
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
|
||||||
{Object.entries(categories).map(([catId, cat]) => (
|
{Object.entries(categories).map(([catId, cat]) => (
|
||||||
<>
|
<>
|
||||||
{cat.flags.map(f => {
|
{cat.flags.map(f => {
|
||||||
const t = totals[f.id]
|
const t = totals[f.id]
|
||||||
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
|
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
|
||||||
if (cat.propagation === 'contains') {
|
if (cat.propagation === 'contains') {
|
||||||
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
|
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
|
||||||
{t.has ? '\u2713' : ''}
|
{t.has ? '\u2713' : ''}
|
||||||
</td>
|
</td>
|
||||||
} else {
|
} else {
|
||||||
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
|
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
|
||||||
{t.has ? '\u2713' : '\u2717'}
|
{t.has ? '\u2713' : '\u2717'}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
{cat.required && (
|
{cat.required && (
|
||||||
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
|
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
container: { background: 'white', padding: '1rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
|
container: { background: 'white', padding: '1rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '0.8rem' },
|
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '0.8rem' },
|
||||||
th: { padding: '0.4rem 0.35rem', textAlign: 'center' as const, borderBottom: '2px solid #ddd', fontSize: '0.7rem', fontWeight: 600, minWidth: '40px', position: 'sticky' as const, top: 0, background: 'white', zIndex: 1 },
|
th: { padding: '0.4rem 0.35rem', textAlign: 'center' as const, borderBottom: '2px solid #ddd', fontSize: '0.7rem', fontWeight: 600, minWidth: '40px', position: 'sticky' as const, top: 0, background: 'white', zIndex: 1 },
|
||||||
td: { padding: '0.35rem 0.5rem', borderBottom: '1px solid #f0f0f0', fontSize: '0.8rem' },
|
td: { padding: '0.35rem 0.5rem', borderBottom: '1px solid #f0f0f0', fontSize: '0.8rem' },
|
||||||
cell: { padding: '0.35rem', textAlign: 'center' as const, borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem' },
|
cell: { padding: '0.35rem', textAlign: 'center' as const, borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem' },
|
||||||
subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' },
|
subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -542,7 +542,7 @@ export default function Review() {
|
||||||
queryKey: ['invoice', id],
|
queryKey: ['invoice', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch invoice')
|
if (!res.ok) throw new Error('Failed to fetch invoice')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -553,14 +553,14 @@ export default function Review() {
|
||||||
|
|
||||||
// Direct URL with token - simpler approach
|
// Direct URL with token - simpler approach
|
||||||
const imageUrl = invoice
|
const imageUrl = invoice
|
||||||
? `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}#toolbar=0&navpanes=0&view=FitH`
|
? `/kitchen/api/invoices/${id}/file#toolbar=0&navpanes=0&view=FitH`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const { data: lineItems, refetch: refetchLineItems } = useQuery<LineItem[]>({
|
const { data: lineItems, refetch: refetchLineItems } = useQuery<LineItem[]>({
|
||||||
queryKey: ['invoice-line-items', id],
|
queryKey: ['invoice-line-items', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch line items')
|
if (!res.ok) throw new Error('Failed to fetch line items')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -577,7 +577,7 @@ export default function Review() {
|
||||||
queryKey: ['invoice-stock-history', id],
|
queryKey: ['invoice-stock-history', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/stock-history`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/stock-history`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch stock history')
|
if (!res.ok) throw new Error('Failed to fetch stock history')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -589,7 +589,7 @@ export default function Review() {
|
||||||
queryKey: ['invoice-duplicates', id],
|
queryKey: ['invoice-duplicates', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/duplicates`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/duplicates`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch duplicates')
|
if (!res.ok) throw new Error('Failed to fetch duplicates')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -601,7 +601,7 @@ export default function Review() {
|
||||||
queryKey: ['invoice-ocr-data', id],
|
queryKey: ['invoice-ocr-data', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/ocr-data`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/ocr-data`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch OCR data')
|
if (!res.ok) throw new Error('Failed to fetch OCR data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -613,7 +613,7 @@ export default function Review() {
|
||||||
queryKey: ['settings'],
|
queryKey: ['settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch settings')
|
if (!res.ok) throw new Error('Failed to fetch settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -639,7 +639,7 @@ export default function Review() {
|
||||||
|
|
||||||
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
|
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ supplier_id: parseInt(supplierId), items: uniqueItems }),
|
body: JSON.stringify({ supplier_id: parseInt(supplierId), items: uniqueItems }),
|
||||||
})
|
})
|
||||||
.then(res => res.ok ? res.json() : [])
|
.then(res => res.ok ? res.json() : [])
|
||||||
|
|
@ -886,7 +886,7 @@ export default function Review() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -916,7 +916,7 @@ export default function Review() {
|
||||||
queryKey: ['po-match', id],
|
queryKey: ['po-match', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/purchase-orders/matching/for-invoice?invoice_id=${id}`, {
|
const res = await fetch(`/kitchen/api/purchase-orders/matching/for-invoice?invoice_id=${id}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { matches: [], linked_po: null }
|
if (!res.ok) return { matches: [], linked_po: null }
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -945,7 +945,7 @@ export default function Review() {
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
|
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (checkRes.ok) {
|
if (checkRes.ok) {
|
||||||
const inv = await checkRes.json()
|
const inv = await checkRes.json()
|
||||||
|
|
@ -971,8 +971,8 @@ export default function Review() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the PDF
|
// Fetch the PDF
|
||||||
const pdfUrl = `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token)}`
|
const pdfUrl = `/kitchen/api/invoices/${id}/file`
|
||||||
const response = await fetch(pdfUrl)
|
const response = await fetch(pdfUrl, { credentials: 'include' })
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
const arrayBuffer = await response.arrayBuffer()
|
||||||
|
|
||||||
// Load the PDF document
|
// Load the PDF document
|
||||||
|
|
@ -1043,8 +1043,8 @@ export default function Review() {
|
||||||
mutationFn: async (data: Partial<Invoice>) => {
|
mutationFn: async (data: Partial<Invoice>) => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1069,7 +1069,7 @@ export default function Review() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete')
|
if (!res.ok) throw new Error('Failed to delete')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1084,8 +1084,8 @@ export default function Review() {
|
||||||
mutationFn: async ({ itemId, data }: { itemId: number; data: Partial<LineItem> }) => {
|
mutationFn: async ({ itemId, data }: { itemId: number; data: Partial<LineItem> }) => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1106,8 +1106,8 @@ export default function Review() {
|
||||||
mutationFn: async (data: Partial<LineItem>) => {
|
mutationFn: async (data: Partial<LineItem>) => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1127,8 +1127,8 @@ export default function Review() {
|
||||||
mutationFn: async (itemId: number) => {
|
mutationFn: async (itemId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete line item')
|
if (!res.ok) throw new Error('Failed to delete line item')
|
||||||
|
|
@ -1145,8 +1145,8 @@ export default function Review() {
|
||||||
mutationFn: async ({ itemId, portionDesc }: { itemId: number; portionDesc?: string }) => {
|
mutationFn: async ({ itemId, portionDesc }: { itemId: number; portionDesc?: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ portion_description: portionDesc || null }),
|
body: JSON.stringify({ portion_description: portionDesc || null }),
|
||||||
|
|
@ -1163,8 +1163,8 @@ export default function Review() {
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
|
|
@ -1184,8 +1184,8 @@ export default function Review() {
|
||||||
mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => {
|
mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => {
|
||||||
const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, {
|
const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ alias, invoice_id: invoiceId }),
|
body: JSON.stringify({ alias, invoice_id: invoiceId }),
|
||||||
|
|
@ -1203,8 +1203,8 @@ export default function Review() {
|
||||||
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
|
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
|
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ alias }),
|
body: JSON.stringify({ alias }),
|
||||||
|
|
@ -1230,7 +1230,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, {
|
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ invoice_id: parseInt(id!) }),
|
body: JSON.stringify({ invoice_id: parseInt(id!) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to link PO')
|
if (!res.ok) throw new Error('Failed to link PO')
|
||||||
|
|
@ -1244,7 +1244,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, {
|
const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to unlink PO')
|
if (!res.ok) throw new Error('Failed to unlink PO')
|
||||||
refetchPoMatch()
|
refetchPoMatch()
|
||||||
|
|
@ -1388,7 +1388,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1425,7 +1425,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1458,7 +1458,7 @@ export default function Review() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1486,7 +1486,7 @@ export default function Review() {
|
||||||
setShowInvoiceNumberModal(true)
|
setShowInvoiceNumberModal(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/parse-invoice-number`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/parse-invoice-number`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to search for invoice number')
|
if (!res.ok) throw new Error('Failed to search for invoice number')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1515,7 +1515,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1533,7 +1533,7 @@ export default function Review() {
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
|
const checkRes = await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (checkRes.ok) {
|
if (checkRes.ok) {
|
||||||
const inv = await checkRes.json()
|
const inv = await checkRes.json()
|
||||||
|
|
@ -1567,7 +1567,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1758,9 +1758,9 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/invoices/line-items/search', {
|
const res = await fetch('/kitchen/api/invoices/line-items/search', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query,
|
query,
|
||||||
|
|
@ -1783,8 +1783,8 @@ export default function Review() {
|
||||||
const promises = lineItems.map(item =>
|
const promises = lineItems.map(item =>
|
||||||
fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, {
|
fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ is_non_stock: !markAsStock }),
|
body: JSON.stringify({ is_non_stock: !markAsStock }),
|
||||||
|
|
@ -1806,7 +1806,7 @@ export default function Review() {
|
||||||
setAiMatchResults([])
|
setAiMatchResults([])
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, {
|
const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1829,7 +1829,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = 'AI analysis failed'
|
let detail = 'AI analysis failed'
|
||||||
|
|
@ -1907,7 +1907,7 @@ export default function Review() {
|
||||||
setDefinitionLoading(true)
|
setDefinitionLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const def = await res.json()
|
const def = await res.json()
|
||||||
|
|
@ -1928,7 +1928,7 @@ export default function Review() {
|
||||||
setAiPackSizeLoading(true)
|
setAiPackSizeLoading(true)
|
||||||
try {
|
try {
|
||||||
const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, {
|
const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (packRes.ok) {
|
if (packRes.ok) {
|
||||||
const packData = await packRes.json()
|
const packData = await packRes.json()
|
||||||
|
|
@ -1962,7 +1962,7 @@ export default function Review() {
|
||||||
setIngredientSearchLoading(true)
|
setIngredientSearchLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
|
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -2055,7 +2055,7 @@ export default function Review() {
|
||||||
// Set ingredient_id on line item
|
// Set ingredient_id on line item
|
||||||
await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ingredient_id: selectedIngredientId }),
|
body: JSON.stringify({ ingredient_id: selectedIngredientId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -2083,7 +2083,7 @@ export default function Review() {
|
||||||
}
|
}
|
||||||
const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
|
const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(sourceData),
|
body: JSON.stringify(sourceData),
|
||||||
})
|
})
|
||||||
if (!srcRes.ok) {
|
if (!srcRes.ok) {
|
||||||
|
|
@ -2610,7 +2610,7 @@ export default function Review() {
|
||||||
)}
|
)}
|
||||||
{isPDF && imageUrl && (
|
{isPDF && imageUrl && (
|
||||||
<a
|
<a
|
||||||
href={`/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}`}
|
href={`/kitchen/api/invoices/${id}/file`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={styles.openPdfLink}
|
style={styles.openPdfLink}
|
||||||
|
|
@ -3094,9 +3094,9 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
await fetch(`/kitchen/api/invoices/${id}`, {
|
await fetch(`/kitchen/api/invoices/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ notes: invoiceNotes })
|
body: JSON.stringify({ notes: invoiceNotes })
|
||||||
})
|
})
|
||||||
|
|
@ -4560,7 +4560,7 @@ export default function Review() {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}>
|
}}>
|
||||||
<img
|
<img
|
||||||
src={`${imageUrl.split('?')[0]}?token=${encodeURIComponent(token || '')}`}
|
src={`${imageUrl.split('?')[0]}`}
|
||||||
alt="Line item location"
|
alt="Line item location"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -4675,7 +4675,7 @@ export default function Review() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, {
|
const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
|
|
|
||||||
|
|
@ -1,448 +1,448 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
interface SalesGPItem {
|
interface SalesGPItem {
|
||||||
menu_item_name: string
|
menu_item_name: string
|
||||||
portion_name: string
|
portion_name: string
|
||||||
category: string
|
category: string
|
||||||
total_qty: number
|
total_qty: number
|
||||||
total_revenue_net: number
|
total_revenue_net: number
|
||||||
dbb_qty: number
|
dbb_qty: number
|
||||||
recipe_id: number | null
|
recipe_id: number | null
|
||||||
recipe_name: string | null
|
recipe_name: string | null
|
||||||
dish_course: string | null
|
dish_course: string | null
|
||||||
cost_per_portion: number | null
|
cost_per_portion: number | null
|
||||||
total_cost: number | null
|
total_cost: number | null
|
||||||
item_gp_percent: number | null
|
item_gp_percent: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SalesGPCourseGroup {
|
interface SalesGPCourseGroup {
|
||||||
course_name: string
|
course_name: string
|
||||||
items: SalesGPItem[]
|
items: SalesGPItem[]
|
||||||
course_revenue: number
|
course_revenue: number
|
||||||
course_cost: number
|
course_cost: number
|
||||||
course_gp_percent: number | null
|
course_gp_percent: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SalesGPResponse {
|
interface SalesGPResponse {
|
||||||
from_date: string
|
from_date: string
|
||||||
to_date: string
|
to_date: string
|
||||||
courses: SalesGPCourseGroup[]
|
courses: SalesGPCourseGroup[]
|
||||||
unmapped_items: SalesGPItem[]
|
unmapped_items: SalesGPItem[]
|
||||||
mapped_revenue_net: number
|
mapped_revenue_net: number
|
||||||
mapped_total_cost: number
|
mapped_total_cost: number
|
||||||
mapped_gp_percent: number | null
|
mapped_gp_percent: number | null
|
||||||
total_all_revenue_net: number
|
total_all_revenue_net: number
|
||||||
unmapped_revenue_net: number
|
unmapped_revenue_net: number
|
||||||
mapped_revenue_percent: number
|
mapped_revenue_percent: number
|
||||||
mapped_item_count: number
|
mapped_item_count: number
|
||||||
unmapped_item_count: number
|
unmapped_item_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DishRecipe {
|
interface DishRecipe {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
menu_section_name: string | null
|
menu_section_name: string | null
|
||||||
cost_per_portion: number | null
|
cost_per_portion: number | null
|
||||||
kds_menu_item_name: string | null
|
kds_menu_item_name: string | null
|
||||||
sambapos_portion_name: string | null
|
sambapos_portion_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SalesGPReport() {
|
export default function SalesGPReport() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10)
|
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10)
|
||||||
const [fromDate, setFromDate] = useState(weekAgo)
|
const [fromDate, setFromDate] = useState(weekAgo)
|
||||||
const [toDate, setToDate] = useState(today)
|
const [toDate, setToDate] = useState(today)
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const [submittedFrom, setSubmittedFrom] = useState(weekAgo)
|
const [submittedFrom, setSubmittedFrom] = useState(weekAgo)
|
||||||
const [submittedTo, setSubmittedTo] = useState(today)
|
const [submittedTo, setSubmittedTo] = useState(today)
|
||||||
|
|
||||||
// Collapsed courses
|
// Collapsed courses
|
||||||
const [collapsedCourses, setCollapsedCourses] = useState<Set<string>>(new Set())
|
const [collapsedCourses, setCollapsedCourses] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// Mapping modal
|
// Mapping modal
|
||||||
const [mappingItem, setMappingItem] = useState<SalesGPItem | null>(null)
|
const [mappingItem, setMappingItem] = useState<SalesGPItem | null>(null)
|
||||||
const [recipeSearch, setRecipeSearch] = useState('')
|
const [recipeSearch, setRecipeSearch] = useState('')
|
||||||
|
|
||||||
// Fetch report data
|
// Fetch report data
|
||||||
const { data: report, isLoading, error } = useQuery<SalesGPResponse>({
|
const { data: report, isLoading, error } = useQuery<SalesGPResponse>({
|
||||||
queryKey: ['sales-gp', submittedFrom, submittedTo],
|
queryKey: ['sales-gp', submittedFrom, submittedTo],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/reports/sales-gp?from_date=${submittedFrom}&to_date=${submittedTo}`, {
|
const res = await fetch(`/kitchen/api/reports/sales-gp?from_date=${submittedFrom}&to_date=${submittedTo}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: 'Request failed' }))
|
const err = await res.json().catch(() => ({ detail: 'Request failed' }))
|
||||||
throw new Error(err.detail || 'Request failed')
|
throw new Error(err.detail || 'Request failed')
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && submitted,
|
enabled: !!token && submitted,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch dish recipes for mapping modal
|
// Fetch dish recipes for mapping modal
|
||||||
const { data: dishRecipes } = useQuery<DishRecipe[]>({
|
const { data: dishRecipes } = useQuery<DishRecipe[]>({
|
||||||
queryKey: ['recipes-for-mapping', recipeSearch],
|
queryKey: ['recipes-for-mapping', recipeSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `/kitchen/api/recipes?recipe_type=dish${recipeSearch ? `&search=${encodeURIComponent(recipeSearch)}` : ''}`
|
const url = `/kitchen/api/recipes?recipe_type=dish${recipeSearch ? `&search=${encodeURIComponent(recipeSearch)}` : ''}`
|
||||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch(url, { credentials: 'include' })
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token && !!mappingItem,
|
enabled: !!token && !!mappingItem,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map unmapped item to recipe
|
// Map unmapped item to recipe
|
||||||
const mapMutation = useMutation({
|
const mapMutation = useMutation({
|
||||||
mutationFn: async ({ recipeId, menuItemName, portionName }: { recipeId: number; menuItemName: string; portionName: string }) => {
|
mutationFn: async ({ recipeId, menuItemName, portionName }: { recipeId: number; menuItemName: string; portionName: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/recipes/${recipeId}`, {
|
const res = await fetch(`/kitchen/api/recipes/${recipeId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
kds_menu_item_name: menuItemName,
|
kds_menu_item_name: menuItemName,
|
||||||
sambapos_portion_name: portionName === 'Normal' ? null : portionName,
|
sambapos_portion_name: portionName === 'Normal' ? null : portionName,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update recipe')
|
if (!res.ok) throw new Error('Failed to update recipe')
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMappingItem(null)
|
setMappingItem(null)
|
||||||
setRecipeSearch('')
|
setRecipeSearch('')
|
||||||
queryClient.invalidateQueries({ queryKey: ['sales-gp'] })
|
queryClient.invalidateQueries({ queryKey: ['sales-gp'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
setSubmittedFrom(fromDate)
|
setSubmittedFrom(fromDate)
|
||||||
setSubmittedTo(toDate)
|
setSubmittedTo(toDate)
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCourse = (name: string) => {
|
const toggleCourse = (name: string) => {
|
||||||
setCollapsedCourses(prev => {
|
setCollapsedCourses(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(name)) next.delete(name)
|
if (next.has(name)) next.delete(name)
|
||||||
else next.add(name)
|
else next.add(name)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmt = (n: number) => Number(n).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
const fmt = (n: number) => Number(n).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
const fmtPct = (n: number | null) => n != null ? `${Number(n).toFixed(1)}%` : '—'
|
const fmtPct = (n: number | null) => n != null ? `${Number(n).toFixed(1)}%` : '—'
|
||||||
|
|
||||||
const gpColor = (pct: number | null) => {
|
const gpColor = (pct: number | null) => {
|
||||||
if (pct == null) return '#888'
|
if (pct == null) return '#888'
|
||||||
if (pct >= 70) return '#16a34a'
|
if (pct >= 70) return '#16a34a'
|
||||||
if (pct >= 60) return '#ca8a04'
|
if (pct >= 60) return '#ca8a04'
|
||||||
return '#dc2626'
|
return '#dc2626'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h2 style={styles.pageTitle}>Sales GP% Report</h2>
|
<h2 style={styles.pageTitle}>Sales GP% Report</h2>
|
||||||
|
|
||||||
{/* Date range selector */}
|
{/* Date range selector */}
|
||||||
<div style={styles.dateBar}>
|
<div style={styles.dateBar}>
|
||||||
<div style={styles.dateGroup}>
|
<div style={styles.dateGroup}>
|
||||||
<label style={styles.dateLabel}>From</label>
|
<label style={styles.dateLabel}>From</label>
|
||||||
<input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} style={styles.dateInput} />
|
<input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} style={styles.dateInput} />
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.dateGroup}>
|
<div style={styles.dateGroup}>
|
||||||
<label style={styles.dateLabel}>To</label>
|
<label style={styles.dateLabel}>To</label>
|
||||||
<input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} style={styles.dateInput} />
|
<input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} style={styles.dateInput} />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleGenerate} style={styles.generateBtn}>Generate</button>
|
<button onClick={handleGenerate} style={styles.generateBtn}>Generate</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <div style={styles.loading}>Loading sales data from SambaPOS...</div>}
|
{isLoading && <div style={styles.loading}>Loading sales data from SambaPOS...</div>}
|
||||||
{error && <div style={styles.error}>{(error as Error).message}</div>}
|
{error && <div style={styles.error}>{(error as Error).message}</div>}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<>
|
<>
|
||||||
{/* Summary banner */}
|
{/* Summary banner */}
|
||||||
<div style={styles.summaryBanner}>
|
<div style={styles.summaryBanner}>
|
||||||
<div style={styles.summaryMain}>
|
<div style={styles.summaryMain}>
|
||||||
<div style={styles.summaryLabel}>Estimated Sales GP%</div>
|
<div style={styles.summaryLabel}>Estimated Sales GP%</div>
|
||||||
<div style={{ ...styles.summaryValue, color: gpColor(report.mapped_gp_percent) }}>
|
<div style={{ ...styles.summaryValue, color: gpColor(report.mapped_gp_percent) }}>
|
||||||
{fmtPct(report.mapped_gp_percent)}
|
{fmtPct(report.mapped_gp_percent)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summarySubtext}>mapped items only</div>
|
<div style={styles.summarySubtext}>mapped items only</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryStats}>
|
<div style={styles.summaryStats}>
|
||||||
<div style={styles.statBox}>
|
<div style={styles.statBox}>
|
||||||
<div style={styles.statLabel}>Mapped Revenue</div>
|
<div style={styles.statLabel}>Mapped Revenue</div>
|
||||||
<div style={styles.statValue}>£{fmt(report.mapped_revenue_net)}</div>
|
<div style={styles.statValue}>£{fmt(report.mapped_revenue_net)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statBox}>
|
<div style={styles.statBox}>
|
||||||
<div style={styles.statLabel}>Mapped Cost</div>
|
<div style={styles.statLabel}>Mapped Cost</div>
|
||||||
<div style={styles.statValue}>£{fmt(report.mapped_total_cost)}</div>
|
<div style={styles.statValue}>£{fmt(report.mapped_total_cost)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.statBox}>
|
<div style={styles.statBox}>
|
||||||
<div style={styles.statLabel}>Total Revenue</div>
|
<div style={styles.statLabel}>Total Revenue</div>
|
||||||
<div style={styles.statValue}>£{fmt(report.total_all_revenue_net)}</div>
|
<div style={styles.statValue}>£{fmt(report.total_all_revenue_net)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Coverage bar */}
|
{/* Coverage bar */}
|
||||||
<div style={styles.coverageSection}>
|
<div style={styles.coverageSection}>
|
||||||
<div style={styles.coverageLabel}>
|
<div style={styles.coverageLabel}>
|
||||||
Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed
|
Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed
|
||||||
<span style={{ color: '#888', marginLeft: '0.5rem' }}>
|
<span style={{ color: '#888', marginLeft: '0.5rem' }}>
|
||||||
({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped)
|
({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.coverageBarBg}>
|
<div style={styles.coverageBarBg}>
|
||||||
<div style={{ ...styles.coverageBarFill, width: `${Math.min(Number(report.mapped_revenue_percent), 100)}%` }} />
|
<div style={{ ...styles.coverageBarFill, width: `${Math.min(Number(report.mapped_revenue_percent), 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course sections */}
|
{/* Course sections */}
|
||||||
{report.courses.map(course => (
|
{report.courses.map(course => (
|
||||||
<div key={course.course_name} style={styles.courseSection}>
|
<div key={course.course_name} style={styles.courseSection}>
|
||||||
<div style={styles.courseHeader} onClick={() => toggleCourse(course.course_name)}>
|
<div style={styles.courseHeader} onClick={() => toggleCourse(course.course_name)}>
|
||||||
<div style={styles.courseTitle}>
|
<div style={styles.courseTitle}>
|
||||||
<span style={styles.collapseIcon}>{collapsedCourses.has(course.course_name) ? '▸' : '▾'}</span>
|
<span style={styles.collapseIcon}>{collapsedCourses.has(course.course_name) ? '▸' : '▾'}</span>
|
||||||
{course.course_name}
|
{course.course_name}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.courseStats}>
|
<div style={styles.courseStats}>
|
||||||
<span style={{ marginRight: '1.5rem' }}>Revenue: £{fmt(course.course_revenue)}</span>
|
<span style={{ marginRight: '1.5rem' }}>Revenue: £{fmt(course.course_revenue)}</span>
|
||||||
<span style={{ marginRight: '1.5rem' }}>Cost: £{fmt(course.course_cost)}</span>
|
<span style={{ marginRight: '1.5rem' }}>Cost: £{fmt(course.course_cost)}</span>
|
||||||
<span style={{ fontWeight: 700, color: gpColor(course.course_gp_percent) }}>
|
<span style={{ fontWeight: 700, color: gpColor(course.course_gp_percent) }}>
|
||||||
GP: {fmtPct(course.course_gp_percent)}
|
GP: {fmtPct(course.course_gp_percent)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!collapsedCourses.has(course.course_name) && (
|
{!collapsedCourses.has(course.course_name) && (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Item</th>
|
<th style={styles.th}>Item</th>
|
||||||
<th style={styles.th}>Portion</th>
|
<th style={styles.th}>Portion</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Cost/Portion</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Cost/Portion</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Total Cost</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Total Cost</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>GP%</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>GP%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{course.items.map((item, idx) => (
|
{course.items.map((item, idx) => (
|
||||||
<tr key={idx} style={styles.tr}>
|
<tr key={idx} style={styles.tr}>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
{item.recipe_id ? (
|
{item.recipe_id ? (
|
||||||
<span
|
<span
|
||||||
title={`Recipe: ${item.recipe_name}`}
|
title={`Recipe: ${item.recipe_name}`}
|
||||||
onClick={() => navigate(`/dishes/${item.recipe_id}`)}
|
onClick={() => navigate(`/dishes/${item.recipe_id}`)}
|
||||||
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
|
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
|
||||||
>{item.menu_item_name}</span>
|
>{item.menu_item_name}</span>
|
||||||
) : item.menu_item_name}
|
) : item.menu_item_name}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
|
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
|
||||||
{item.portion_name === 'Normal' ? '—' : item.portion_name}
|
{item.portion_name === 'Normal' ? '—' : item.portion_name}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.total_qty}
|
{item.total_qty}
|
||||||
{item.dbb_qty > 0 && (
|
{item.dbb_qty > 0 && (
|
||||||
<span
|
<span
|
||||||
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used for GP calculation`}
|
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used for GP calculation`}
|
||||||
style={styles.dbbBadge}
|
style={styles.dbbBadge}
|
||||||
>
|
>
|
||||||
{item.dbb_qty} DBB
|
{item.dbb_qty} DBB
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>£{fmt(item.total_revenue_net)}</td>
|
<td style={{ ...styles.td, textAlign: 'right' }}>£{fmt(item.total_revenue_net)}</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'}
|
{item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'}
|
{item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: gpColor(item.item_gp_percent) }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: gpColor(item.item_gp_percent) }}>
|
||||||
{fmtPct(item.item_gp_percent)}
|
{fmtPct(item.item_gp_percent)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Unmapped items section */}
|
{/* Unmapped items section */}
|
||||||
{report.unmapped_items.length > 0 && (
|
{report.unmapped_items.length > 0 && (
|
||||||
<div style={styles.unmappedSection}>
|
<div style={styles.unmappedSection}>
|
||||||
<div style={styles.unmappedHeader}>
|
<div style={styles.unmappedHeader}>
|
||||||
<span>Unmapped Items</span>
|
<span>Unmapped Items</span>
|
||||||
<span style={styles.unmappedSubtext}>
|
<span style={styles.unmappedSubtext}>
|
||||||
£{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales)
|
£{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Item</th>
|
<th style={styles.th}>Item</th>
|
||||||
<th style={styles.th}>Portion</th>
|
<th style={styles.th}>Portion</th>
|
||||||
<th style={styles.th}>Category</th>
|
<th style={styles.th}>Category</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'center' }}>Action</th>
|
<th style={{ ...styles.th, textAlign: 'center' }}>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.unmapped_items.map((item, idx) => (
|
{report.unmapped_items.map((item, idx) => (
|
||||||
<tr key={idx} style={styles.tr}>
|
<tr key={idx} style={styles.tr}>
|
||||||
<td style={styles.td}>{item.menu_item_name}</td>
|
<td style={styles.td}>{item.menu_item_name}</td>
|
||||||
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
|
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
|
||||||
{item.portion_name === 'Normal' ? '—' : item.portion_name}
|
{item.portion_name === 'Normal' ? '—' : item.portion_name}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, color: '#888' }}>{item.category}</td>
|
<td style={{ ...styles.td, color: '#888' }}>{item.category}</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.total_qty}
|
{item.total_qty}
|
||||||
{item.dbb_qty > 0 && (
|
{item.dbb_qty > 0 && (
|
||||||
<span
|
<span
|
||||||
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used`}
|
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used`}
|
||||||
style={styles.dbbBadge}
|
style={styles.dbbBadge}
|
||||||
>
|
>
|
||||||
{item.dbb_qty} DBB
|
{item.dbb_qty} DBB
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>£{fmt(item.total_revenue_net)}</td>
|
<td style={{ ...styles.td, textAlign: 'right' }}>£{fmt(item.total_revenue_net)}</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'center' }}>
|
<td style={{ ...styles.td, textAlign: 'center' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMappingItem(item); setRecipeSearch('') }}
|
onClick={() => { setMappingItem(item); setRecipeSearch('') }}
|
||||||
style={styles.mapBtn}
|
style={styles.mapBtn}
|
||||||
>Map</button>
|
>Map</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recipe mapping modal */}
|
{/* Recipe mapping modal */}
|
||||||
{mappingItem && (
|
{mappingItem && (
|
||||||
<div style={styles.overlay}>
|
<div style={styles.overlay}>
|
||||||
<div style={styles.modal}>
|
<div style={styles.modal}>
|
||||||
<div style={styles.modalHeader}>
|
<div style={styles.modalHeader}>
|
||||||
<h3 style={{ margin: 0 }}>
|
<h3 style={{ margin: 0 }}>
|
||||||
Map: {mappingItem.menu_item_name}
|
Map: {mappingItem.menu_item_name}
|
||||||
{mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`}
|
{mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => { setMappingItem(null); setRecipeSearch('') }} style={styles.closeBtn}>✕</button>
|
<button onClick={() => { setMappingItem(null); setRecipeSearch('') }} style={styles.closeBtn}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.modalBody}>
|
<div style={styles.modalBody}>
|
||||||
<input
|
<input
|
||||||
value={recipeSearch}
|
value={recipeSearch}
|
||||||
onChange={(e) => setRecipeSearch(e.target.value)}
|
onChange={(e) => setRecipeSearch(e.target.value)}
|
||||||
style={{ ...styles.searchInput, marginBottom: '0.75rem' }}
|
style={{ ...styles.searchInput, marginBottom: '0.75rem' }}
|
||||||
placeholder="Search dish recipes..."
|
placeholder="Search dish recipes..."
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{!dishRecipes ? (
|
{!dishRecipes ? (
|
||||||
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>Loading recipes...</div>
|
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>Loading recipes...</div>
|
||||||
) : dishRecipes.length === 0 ? (
|
) : dishRecipes.length === 0 ? (
|
||||||
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>No dish recipes found.</div>
|
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>No dish recipes found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: '350px', overflow: 'auto' }}>
|
<div style={{ maxHeight: '350px', overflow: 'auto' }}>
|
||||||
{dishRecipes.map(r => (
|
{dishRecipes.map(r => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
onClick={() => mapMutation.mutate({
|
onClick={() => mapMutation.mutate({
|
||||||
recipeId: r.id,
|
recipeId: r.id,
|
||||||
menuItemName: mappingItem.menu_item_name,
|
menuItemName: mappingItem.menu_item_name,
|
||||||
portionName: mappingItem.portion_name,
|
portionName: mappingItem.portion_name,
|
||||||
})}
|
})}
|
||||||
style={styles.recipeRow}
|
style={styles.recipeRow}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f0f7ff')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#f0f7ff')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: '#888' }}>
|
<div style={{ fontSize: '0.75rem', color: '#888' }}>
|
||||||
{r.menu_section_name || 'No course'}
|
{r.menu_section_name || 'No course'}
|
||||||
{r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`}
|
{r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{r.kds_menu_item_name && (
|
{r.kds_menu_item_name && (
|
||||||
<div style={{ fontSize: '0.7rem', color: '#999' }}>
|
<div style={{ fontSize: '0.7rem', color: '#999' }}>
|
||||||
Already mapped: {r.kds_menu_item_name}
|
Already mapped: {r.kds_menu_item_name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' },
|
container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' },
|
||||||
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
|
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
|
||||||
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' },
|
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' },
|
||||||
dateGroup: { display: 'flex', flexDirection: 'column', gap: '0.2rem' },
|
dateGroup: { display: 'flex', flexDirection: 'column', gap: '0.2rem' },
|
||||||
dateLabel: { fontSize: '0.75rem', fontWeight: 600, color: '#666' },
|
dateLabel: { fontSize: '0.75rem', fontWeight: 600, color: '#666' },
|
||||||
dateInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
dateInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
|
||||||
generateBtn: { padding: '0.5rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
generateBtn: { padding: '0.5rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
|
||||||
loading: { padding: '2rem', textAlign: 'center', color: '#888' },
|
loading: { padding: '2rem', textAlign: 'center', color: '#888' },
|
||||||
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
|
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
summaryBanner: { background: '#f8f9fa', padding: '1.25rem', borderRadius: '8px', border: '2px solid #e0e0e0', marginBottom: '1.5rem' },
|
summaryBanner: { background: '#f8f9fa', padding: '1.25rem', borderRadius: '8px', border: '2px solid #e0e0e0', marginBottom: '1.5rem' },
|
||||||
summaryMain: { textAlign: 'center', marginBottom: '1rem' },
|
summaryMain: { textAlign: 'center', marginBottom: '1rem' },
|
||||||
summaryLabel: { fontSize: '0.85rem', fontWeight: 600, color: '#666', textTransform: 'uppercase' },
|
summaryLabel: { fontSize: '0.85rem', fontWeight: 600, color: '#666', textTransform: 'uppercase' },
|
||||||
summaryValue: { fontSize: '2.5rem', fontWeight: 800, lineHeight: 1.2 },
|
summaryValue: { fontSize: '2.5rem', fontWeight: 800, lineHeight: 1.2 },
|
||||||
summarySubtext: { fontSize: '0.75rem', color: '#999' },
|
summarySubtext: { fontSize: '0.75rem', color: '#999' },
|
||||||
summaryStats: { display: 'flex', justifyContent: 'center', gap: '2rem', marginBottom: '1rem', flexWrap: 'wrap' },
|
summaryStats: { display: 'flex', justifyContent: 'center', gap: '2rem', marginBottom: '1rem', flexWrap: 'wrap' },
|
||||||
statBox: { textAlign: 'center' },
|
statBox: { textAlign: 'center' },
|
||||||
statLabel: { fontSize: '0.75rem', color: '#666', fontWeight: 600 },
|
statLabel: { fontSize: '0.75rem', color: '#666', fontWeight: 600 },
|
||||||
statValue: { fontSize: '1.1rem', fontWeight: 700 },
|
statValue: { fontSize: '1.1rem', fontWeight: 700 },
|
||||||
coverageSection: { borderTop: '1px solid #e0e0e0', paddingTop: '0.75rem' },
|
coverageSection: { borderTop: '1px solid #e0e0e0', paddingTop: '0.75rem' },
|
||||||
coverageLabel: { fontSize: '0.8rem', color: '#555', marginBottom: '0.4rem' },
|
coverageLabel: { fontSize: '0.8rem', color: '#555', marginBottom: '0.4rem' },
|
||||||
coverageBarBg: { height: '8px', background: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' },
|
coverageBarBg: { height: '8px', background: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' },
|
||||||
coverageBarFill: { height: '100%', background: '#16a34a', borderRadius: '4px', transition: 'width 0.3s' },
|
coverageBarFill: { height: '100%', background: '#16a34a', borderRadius: '4px', transition: 'width 0.3s' },
|
||||||
|
|
||||||
// Course sections
|
// Course sections
|
||||||
courseSection: { marginBottom: '1rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' },
|
courseSection: { marginBottom: '1rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' },
|
||||||
courseHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', flexWrap: 'wrap', gap: '0.5rem' },
|
courseHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', flexWrap: 'wrap', gap: '0.5rem' },
|
||||||
courseTitle: { fontWeight: 700, fontSize: '1rem' },
|
courseTitle: { fontWeight: 700, fontSize: '1rem' },
|
||||||
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
|
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
|
||||||
courseStats: { fontSize: '0.85rem', color: '#555' },
|
courseStats: { fontSize: '0.85rem', color: '#555' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' },
|
table: { width: '100%', borderCollapse: 'collapse' },
|
||||||
th: { padding: '0.5rem 0.75rem', textAlign: 'left', borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666', background: '#fafafa' },
|
th: { padding: '0.5rem 0.75rem', textAlign: 'left', borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666', background: '#fafafa' },
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.4rem 0.75rem', fontSize: '0.85rem' },
|
td: { padding: '0.4rem 0.75rem', fontSize: '0.85rem' },
|
||||||
|
|
||||||
// Unmapped
|
// Unmapped
|
||||||
unmappedSection: { marginTop: '1.5rem', border: '1px solid #f0c040', borderRadius: '8px', overflow: 'hidden' },
|
unmappedSection: { marginTop: '1.5rem', border: '1px solid #f0c040', borderRadius: '8px', overflow: 'hidden' },
|
||||||
unmappedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fffbeb', fontWeight: 700, fontSize: '1rem', flexWrap: 'wrap', gap: '0.5rem' },
|
unmappedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fffbeb', fontWeight: 700, fontSize: '1rem', flexWrap: 'wrap', gap: '0.5rem' },
|
||||||
unmappedSubtext: { fontSize: '0.85rem', fontWeight: 400, color: '#92400e' },
|
unmappedSubtext: { fontSize: '0.85rem', fontWeight: 400, color: '#92400e' },
|
||||||
mapBtn: { padding: '0.25rem 0.75rem', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 },
|
mapBtn: { padding: '0.25rem 0.75rem', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 },
|
||||||
|
|
||||||
// DBB badge
|
// DBB badge
|
||||||
dbbBadge: { display: 'inline-block', marginLeft: '0.35rem', padding: '0.1rem 0.35rem', background: '#ede9fe', color: '#6d28d9', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'default', whiteSpace: 'nowrap' as const },
|
dbbBadge: { display: 'inline-block', marginLeft: '0.35rem', padding: '0.1rem 0.35rem', background: '#ede9fe', color: '#6d28d9', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'default', whiteSpace: 'nowrap' as const },
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
||||||
modal: { background: 'white', borderRadius: '10px', width: '500px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
|
modal: { background: 'white', borderRadius: '10px', width: '500px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' },
|
||||||
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
|
||||||
modalBody: { padding: '1.25rem' },
|
modalBody: { padding: '1.25rem' },
|
||||||
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
|
||||||
searchInput: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' },
|
searchInput: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' },
|
||||||
recipeRow: { padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
recipeRow: { padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export default function SearchDefinitions() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -124,12 +124,12 @@ export default function SearchDefinitions() {
|
||||||
params.set('limit', '200')
|
params.set('limit', '200')
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/search/definitions?${params}`, {
|
const res = await fetch(`/kitchen/api/search/definitions?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Search failed')
|
if (!res.ok) throw new Error('Search failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch invoice line items when editing (to find matching line item for bounding box)
|
// Fetch invoice line items when editing (to find matching line item for bounding box)
|
||||||
|
|
@ -137,7 +137,7 @@ export default function SearchDefinitions() {
|
||||||
queryKey: ['invoice-line-items', editingDef?.source_invoice_id],
|
queryKey: ['invoice-line-items', editingDef?.source_invoice_id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/line-items`, {
|
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/line-items`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch line items')
|
if (!res.ok) throw new Error('Failed to fetch line items')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -150,7 +150,7 @@ export default function SearchDefinitions() {
|
||||||
queryKey: ['invoice-ocr-data', editingDef?.source_invoice_id],
|
queryKey: ['invoice-ocr-data', editingDef?.source_invoice_id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/ocr-data`, {
|
const res = await fetch(`/kitchen/api/invoices/${editingDef!.source_invoice_id}/ocr-data`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch OCR data')
|
if (!res.ok) throw new Error('Failed to fetch OCR data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -163,8 +163,8 @@ export default function SearchDefinitions() {
|
||||||
mutationFn: async (data: { id: number; updates: typeof editFormData }) => {
|
mutationFn: async (data: { id: number; updates: typeof editFormData }) => {
|
||||||
const res = await fetch(`/kitchen/api/search/definitions/${data.id}`, {
|
const res = await fetch(`/kitchen/api/search/definitions/${data.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data.updates),
|
body: JSON.stringify(data.updates),
|
||||||
|
|
@ -195,7 +195,7 @@ export default function SearchDefinitions() {
|
||||||
setInvoiceImageUrl(url)
|
setInvoiceImageUrl(url)
|
||||||
|
|
||||||
// Check if PDF by fetching headers
|
// Check if PDF by fetching headers
|
||||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
fetch(url, { credentials: 'include' })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const contentType = res.headers.get('content-type') || ''
|
const contentType = res.headers.get('content-type') || ''
|
||||||
setIsPDF(contentType.includes('pdf'))
|
setIsPDF(contentType.includes('pdf'))
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export default function SearchInvoices() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -104,12 +104,12 @@ export default function SearchInvoices() {
|
||||||
params.set('limit', '200')
|
params.set('limit', '200')
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/search/invoices?${params}`, {
|
const res = await fetch(`/kitchen/api/search/invoices?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Search failed')
|
if (!res.ok) throw new Error('Search failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleGroup = (name: string) => {
|
const toggleGroup = (name: string) => {
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ export default function SearchLineItems() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -157,7 +157,7 @@ export default function SearchLineItems() {
|
||||||
queryKey: ['search-settings'],
|
queryKey: ['search-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/search/settings', {
|
const res = await fetch('/kitchen/api/search/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch search settings')
|
if (!res.ok) throw new Error('Failed to fetch search settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -179,12 +179,12 @@ export default function SearchLineItems() {
|
||||||
params.set('limit', '200')
|
params.set('limit', '200')
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
|
const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Search failed')
|
if (!res.ok) throw new Error('Search failed')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch alias suggestions for unmapped items without product codes
|
// Fetch alias suggestions for unmapped items without product codes
|
||||||
|
|
@ -207,7 +207,7 @@ export default function SearchLineItems() {
|
||||||
const promises = Array.from(bySupplier.entries()).map(([sid, items]) =>
|
const promises = Array.from(bySupplier.entries()).map(([sid, items]) =>
|
||||||
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
|
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ supplier_id: sid, items }),
|
body: JSON.stringify({ supplier_id: sid, items }),
|
||||||
})
|
})
|
||||||
.then(res => res.ok ? res.json() : [])
|
.then(res => res.ok ? res.json() : [])
|
||||||
|
|
@ -225,8 +225,8 @@ export default function SearchLineItems() {
|
||||||
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
|
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
|
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ alias }),
|
body: JSON.stringify({ alias }),
|
||||||
|
|
@ -301,7 +301,7 @@ export default function SearchLineItems() {
|
||||||
setIngredientSearchLoading(true)
|
setIngredientSearchLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
|
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) setIngredientSuggestions(await res.json())
|
if (res.ok) setIngredientSuggestions(await res.json())
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
@ -373,7 +373,7 @@ export default function SearchLineItems() {
|
||||||
if (modalItem.most_recent_line_item_id && modalItem.most_recent_invoice_id) {
|
if (modalItem.most_recent_line_item_id && modalItem.most_recent_invoice_id) {
|
||||||
await fetch(`/kitchen/api/invoices/${modalItem.most_recent_invoice_id}/line-items/${modalItem.most_recent_line_item_id}`, {
|
await fetch(`/kitchen/api/invoices/${modalItem.most_recent_invoice_id}/line-items/${modalItem.most_recent_line_item_id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pack_quantity: costEdits.pack_quantity || null,
|
pack_quantity: costEdits.pack_quantity || null,
|
||||||
unit_size: costEdits.unit_size || null,
|
unit_size: costEdits.unit_size || null,
|
||||||
|
|
@ -401,7 +401,7 @@ export default function SearchLineItems() {
|
||||||
|
|
||||||
await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
|
await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(sourceData),
|
body: JSON.stringify(sourceData),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function Suppliers() {
|
||||||
queryKey: ['suppliers'],
|
queryKey: ['suppliers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
if (!res.ok) throw new Error('Failed to fetch suppliers')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -43,8 +43,8 @@ export default function Suppliers() {
|
||||||
mutationFn: async ({ name, aliases, skip_dext, order_email, account_number }: { name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
|
mutationFn: async ({ name, aliases, skip_dext, order_email, account_number }: { name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/', {
|
const res = await fetch('/kitchen/api/suppliers/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
|
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
|
||||||
|
|
@ -68,8 +68,8 @@ export default function Suppliers() {
|
||||||
mutationFn: async ({ id, name, aliases, skip_dext, order_email, account_number }: { id: number; name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
|
mutationFn: async ({ id, name, aliases, skip_dext, order_email, account_number }: { id: number; name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
|
||||||
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
|
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
|
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
|
||||||
|
|
@ -93,7 +93,7 @@ export default function Suppliers() {
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
|
const res = await fetch(`/kitchen/api/suppliers/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete supplier')
|
if (!res.ok) throw new Error('Failed to delete supplier')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ export default function SupportButton() {
|
||||||
queryKey: ['support-enabled'],
|
queryKey: ['support-enabled'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/support/enabled', {
|
const res = await fetch('/kitchen/api/support/enabled', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { enabled: false }
|
if (!res.ok) return { enabled: false }
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -33,9 +33,9 @@ export default function SupportButton() {
|
||||||
mutationFn: async (data: { description: string; screenshot: string }) => {
|
mutationFn: async (data: { description: string; screenshot: string }) => {
|
||||||
const res = await fetch('/kitchen/api/support/request', {
|
const res = await fetch('/kitchen/api/support/request', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
description: data.description,
|
description: data.description,
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export default function Upload() {
|
||||||
|
|
||||||
const res = await fetch('/kitchen/api/invoices/upload', {
|
const res = await fetch('/kitchen/api/invoices/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,413 +1,413 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAuth } from '../App'
|
import { useAuth } from '../App'
|
||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
interface UsageVarianceItem {
|
interface UsageVarianceItem {
|
||||||
ingredient_id: number
|
ingredient_id: number
|
||||||
ingredient_name: string
|
ingredient_name: string
|
||||||
category: string | null
|
category: string | null
|
||||||
standard_unit: string
|
standard_unit: string
|
||||||
theoretical_qty: number
|
theoretical_qty: number
|
||||||
theoretical_value: number
|
theoretical_value: number
|
||||||
dishes_using: number
|
dishes_using: number
|
||||||
actual_qty: number | null
|
actual_qty: number | null
|
||||||
actual_value: number | null
|
actual_value: number | null
|
||||||
invoice_count: number
|
invoice_count: number
|
||||||
variance_qty: number | null
|
variance_qty: number | null
|
||||||
variance_pct: number | null
|
variance_pct: number | null
|
||||||
variance_value: number | null
|
variance_value: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnmappedSaleItem {
|
interface UnmappedSaleItem {
|
||||||
menu_item_name: string
|
menu_item_name: string
|
||||||
portion_name: string
|
portion_name: string
|
||||||
total_qty: number
|
total_qty: number
|
||||||
category: string | null
|
category: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageVarianceResponse {
|
interface UsageVarianceResponse {
|
||||||
from_date: string
|
from_date: string
|
||||||
to_date: string
|
to_date: string
|
||||||
items: UsageVarianceItem[]
|
items: UsageVarianceItem[]
|
||||||
total_theoretical_value: number
|
total_theoretical_value: number
|
||||||
total_actual_value: number
|
total_actual_value: number
|
||||||
total_variance_value: number
|
total_variance_value: number
|
||||||
mapped_dish_count: number
|
mapped_dish_count: number
|
||||||
unmapped_dish_count: number
|
unmapped_dish_count: number
|
||||||
ingredients_with_purchases: number
|
ingredients_with_purchases: number
|
||||||
ingredients_without_purchases: number
|
ingredients_without_purchases: number
|
||||||
unmapped_sales: UnmappedSaleItem[]
|
unmapped_sales: UnmappedSaleItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Helpers ============
|
// ============ Helpers ============
|
||||||
|
|
||||||
function fmtQty(qty: number, unit: string): string {
|
function fmtQty(qty: number, unit: string): string {
|
||||||
if (unit === 'g' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} kg`
|
if (unit === 'g' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} kg`
|
||||||
if (unit === 'ml' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} ltr`
|
if (unit === 'ml' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} ltr`
|
||||||
if (unit === 'g') return `${qty.toFixed(0)} g`
|
if (unit === 'g') return `${qty.toFixed(0)} g`
|
||||||
if (unit === 'ml') return `${qty.toFixed(0)} ml`
|
if (unit === 'ml') return `${qty.toFixed(0)} ml`
|
||||||
if (unit === 'kg') return `${qty.toFixed(2)} kg`
|
if (unit === 'kg') return `${qty.toFixed(2)} kg`
|
||||||
if (unit === 'ltr') return `${qty.toFixed(2)} ltr`
|
if (unit === 'ltr') return `${qty.toFixed(2)} ltr`
|
||||||
if (unit === 'each') return `${qty.toFixed(1)}`
|
if (unit === 'each') return `${qty.toFixed(1)}`
|
||||||
return `${qty.toFixed(2)} ${unit}`
|
return `${qty.toFixed(2)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(val: number | null | undefined): string {
|
function fmt(val: number | null | undefined): string {
|
||||||
if (val == null) return '—'
|
if (val == null) return '—'
|
||||||
return `£${Number(val).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `£${Number(val).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPct(val: number | null | undefined): string {
|
function fmtPct(val: number | null | undefined): string {
|
||||||
if (val == null) return '—'
|
if (val == null) return '—'
|
||||||
const v = Number(val)
|
const v = Number(val)
|
||||||
const sign = v > 0 ? '+' : ''
|
const sign = v > 0 ? '+' : ''
|
||||||
return `${sign}${v.toFixed(1)}%`
|
return `${sign}${v.toFixed(1)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
function varianceColor(pct: number | null, absVal: number | null): string {
|
function varianceColor(pct: number | null, absVal: number | null): string {
|
||||||
if (pct == null && absVal == null) return '#666'
|
if (pct == null && absVal == null) return '#666'
|
||||||
const absPct = Math.abs(pct ?? 0)
|
const absPct = Math.abs(pct ?? 0)
|
||||||
const absValue = Math.abs(absVal ?? 0)
|
const absValue = Math.abs(absVal ?? 0)
|
||||||
if (absPct > 50 || absValue > 50) return '#dc2626' // red
|
if (absPct > 50 || absValue > 50) return '#dc2626' // red
|
||||||
if (absPct > 15 || absValue > 15) return '#d97706' // amber
|
if (absPct > 15 || absValue > 15) return '#d97706' // amber
|
||||||
return '#16a34a' // green
|
return '#16a34a' // green
|
||||||
}
|
}
|
||||||
|
|
||||||
function varianceBg(pct: number | null, absVal: number | null): string {
|
function varianceBg(pct: number | null, absVal: number | null): string {
|
||||||
if (pct == null && absVal == null) return 'transparent'
|
if (pct == null && absVal == null) return 'transparent'
|
||||||
const absPct = Math.abs(pct ?? 0)
|
const absPct = Math.abs(pct ?? 0)
|
||||||
const absValue = Math.abs(absVal ?? 0)
|
const absValue = Math.abs(absVal ?? 0)
|
||||||
if (absPct > 50 || absValue > 50) return '#fef2f2'
|
if (absPct > 50 || absValue > 50) return '#fef2f2'
|
||||||
if (absPct > 15 || absValue > 15) return '#fffbeb'
|
if (absPct > 15 || absValue > 15) return '#fffbeb'
|
||||||
return 'transparent'
|
return 'transparent'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Component ============
|
// ============ Component ============
|
||||||
|
|
||||||
type SortKey = 'variance_value' | 'variance_pct' | 'name'
|
type SortKey = 'variance_value' | 'variance_pct' | 'name'
|
||||||
|
|
||||||
export default function UsageVarianceReport() {
|
export default function UsageVarianceReport() {
|
||||||
const { token } = useAuth()
|
const { token } = useAuth()
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
|
const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
|
||||||
const [fromDate, setFromDate] = useState(monthAgo)
|
const [fromDate, setFromDate] = useState(monthAgo)
|
||||||
const [toDate, setToDate] = useState(today)
|
const [toDate, setToDate] = useState(today)
|
||||||
const [result, setResult] = useState<UsageVarianceResponse | null>(null)
|
const [result, setResult] = useState<UsageVarianceResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [showAll, setShowAll] = useState(true)
|
const [showAll, setShowAll] = useState(true)
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('variance_value')
|
const [sortKey, setSortKey] = useState<SortKey>('variance_value')
|
||||||
const [unmappedCollapsed, setUnmappedCollapsed] = useState(true)
|
const [unmappedCollapsed, setUnmappedCollapsed] = useState(true)
|
||||||
|
|
||||||
const fetchReport = async () => {
|
const fetchReport = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/kitchen/api/reports/usage-variance?from_date=${fromDate}&to_date=${toDate}`,
|
`/kitchen/api/reports/usage-variance?from_date=${fromDate}&to_date=${toDate}`,
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ credentials: 'include' }
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: 'Request failed' }))
|
const err = await res.json().catch(() => ({ detail: 'Request failed' }))
|
||||||
throw new Error(err.detail || 'Request failed')
|
throw new Error(err.detail || 'Request failed')
|
||||||
}
|
}
|
||||||
const data: UsageVarianceResponse = await res.json()
|
const data: UsageVarianceResponse = await res.json()
|
||||||
setResult(data)
|
setResult(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (iso: string) => {
|
const formatDate = (iso: string) => {
|
||||||
const d = new Date(iso + 'T00:00:00')
|
const d = new Date(iso + 'T00:00:00')
|
||||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter + sort items
|
// Filter + sort items
|
||||||
let items = result?.items ?? []
|
let items = result?.items ?? []
|
||||||
if (search) {
|
if (search) {
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
items = items.filter(i => i.ingredient_name.toLowerCase().includes(s) || (i.category || '').toLowerCase().includes(s))
|
items = items.filter(i => i.ingredient_name.toLowerCase().includes(s) || (i.category || '').toLowerCase().includes(s))
|
||||||
}
|
}
|
||||||
if (!showAll) {
|
if (!showAll) {
|
||||||
items = items.filter(i => Math.abs(i.variance_pct ?? 0) > 10 || Math.abs(i.variance_value ?? 0) > 5)
|
items = items.filter(i => Math.abs(i.variance_pct ?? 0) > 10 || Math.abs(i.variance_value ?? 0) > 5)
|
||||||
}
|
}
|
||||||
items = [...items].sort((a, b) => {
|
items = [...items].sort((a, b) => {
|
||||||
if (sortKey === 'name') return a.ingredient_name.localeCompare(b.ingredient_name)
|
if (sortKey === 'name') return a.ingredient_name.localeCompare(b.ingredient_name)
|
||||||
if (sortKey === 'variance_pct') return Math.abs(b.variance_pct ?? 0) - Math.abs(a.variance_pct ?? 0)
|
if (sortKey === 'variance_pct') return Math.abs(b.variance_pct ?? 0) - Math.abs(a.variance_pct ?? 0)
|
||||||
return Math.abs(b.variance_value ?? 0) - Math.abs(a.variance_value ?? 0)
|
return Math.abs(b.variance_value ?? 0) - Math.abs(a.variance_value ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const r = result
|
const r = result
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<h2 style={styles.pageTitle}>Theoretical vs Actual Usage</h2>
|
<h2 style={styles.pageTitle}>Theoretical vs Actual Usage</h2>
|
||||||
|
|
||||||
{/* Date range selector */}
|
{/* Date range selector */}
|
||||||
<div style={styles.dateBar}>
|
<div style={styles.dateBar}>
|
||||||
<label style={styles.dateLabel}>
|
<label style={styles.dateLabel}>
|
||||||
From
|
From
|
||||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} style={styles.dateInput} />
|
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} style={styles.dateInput} />
|
||||||
</label>
|
</label>
|
||||||
<label style={styles.dateLabel}>
|
<label style={styles.dateLabel}>
|
||||||
To
|
To
|
||||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} style={styles.dateInput} />
|
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} style={styles.dateInput} />
|
||||||
</label>
|
</label>
|
||||||
<button onClick={fetchReport} disabled={loading} style={styles.generateBtn}>
|
<button onClick={fetchReport} disabled={loading} style={styles.generateBtn}>
|
||||||
{loading ? 'Loading...' : 'Generate'}
|
{loading ? 'Loading...' : 'Generate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div style={styles.error}>{error}</div>}
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{r && (
|
{r && (
|
||||||
<>
|
<>
|
||||||
{/* Summary banner */}
|
{/* Summary banner */}
|
||||||
<div style={styles.summaryBar}>
|
<div style={styles.summaryBar}>
|
||||||
<div style={styles.summaryPeriod}>
|
<div style={styles.summaryPeriod}>
|
||||||
{formatDate(r.from_date)} – {formatDate(r.to_date)}
|
{formatDate(r.from_date)} – {formatDate(r.to_date)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryGrid}>
|
<div style={styles.summaryGrid}>
|
||||||
<div style={styles.summaryItem}>
|
<div style={styles.summaryItem}>
|
||||||
<div style={styles.summaryLabel}>Theoretical Cost</div>
|
<div style={styles.summaryLabel}>Theoretical Cost</div>
|
||||||
<div style={styles.summaryValue}>{fmt(r.total_theoretical_value)}</div>
|
<div style={styles.summaryValue}>{fmt(r.total_theoretical_value)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryItem}>
|
<div style={styles.summaryItem}>
|
||||||
<div style={styles.summaryLabel}>Actual Purchased</div>
|
<div style={styles.summaryLabel}>Actual Purchased</div>
|
||||||
<div style={styles.summaryValue}>{fmt(r.total_actual_value)}</div>
|
<div style={styles.summaryValue}>{fmt(r.total_actual_value)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryItem}>
|
<div style={styles.summaryItem}>
|
||||||
<div style={styles.summaryLabel}>Variance</div>
|
<div style={styles.summaryLabel}>Variance</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
...styles.summaryValue,
|
...styles.summaryValue,
|
||||||
color: r.total_variance_value > 50 ? '#f87171' : r.total_variance_value < -50 ? '#fbbf24' : '#4ade80',
|
color: r.total_variance_value > 50 ? '#f87171' : r.total_variance_value < -50 ? '#fbbf24' : '#4ade80',
|
||||||
}}>
|
}}>
|
||||||
{fmt(r.total_variance_value)}
|
{fmt(r.total_variance_value)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.summaryCounts}>
|
<div style={styles.summaryCounts}>
|
||||||
<span style={styles.countBadgeGreen}>{r.mapped_dish_count} dishes mapped</span>
|
<span style={styles.countBadgeGreen}>{r.mapped_dish_count} dishes mapped</span>
|
||||||
{r.unmapped_dish_count > 0 && (
|
{r.unmapped_dish_count > 0 && (
|
||||||
<span style={styles.countBadgeAmber}>{r.unmapped_dish_count} unmapped</span>
|
<span style={styles.countBadgeAmber}>{r.unmapped_dish_count} unmapped</span>
|
||||||
)}
|
)}
|
||||||
<span style={styles.countBadgeBlue}>{r.ingredients_with_purchases} ingredients with purchases</span>
|
<span style={styles.countBadgeBlue}>{r.ingredients_with_purchases} ingredients with purchases</span>
|
||||||
{r.ingredients_without_purchases > 0 && (
|
{r.ingredients_without_purchases > 0 && (
|
||||||
<span style={styles.countBadgeBlue}>{r.ingredients_without_purchases} theoretical only</span>
|
<span style={styles.countBadgeBlue}>{r.ingredients_without_purchases} theoretical only</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.footnote}>
|
<div style={styles.footnote}>
|
||||||
Variance = Actual - Theoretical. Positive = over-purchased. This report estimates usage from recipes and may not account for stock carried forward, staff meals, or wastage.
|
Variance = Actual - Theoretical. Positive = over-purchased. This report estimates usage from recipes and may not account for stock carried forward, staff meals, or wastage.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={styles.controlsBar}>
|
<div style={styles.controlsBar}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search ingredient..."
|
placeholder="Search ingredient..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
/>
|
/>
|
||||||
<label style={styles.toggleLabel}>
|
<label style={styles.toggleLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!showAll}
|
checked={!showAll}
|
||||||
onChange={e => setShowAll(!e.target.checked)}
|
onChange={e => setShowAll(!e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{' '}Variances only
|
{' '}Variances only
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={e => setSortKey(e.target.value as SortKey)}
|
onChange={e => setSortKey(e.target.value as SortKey)}
|
||||||
style={styles.sortSelect}
|
style={styles.sortSelect}
|
||||||
>
|
>
|
||||||
<option value="variance_value">Sort: Variance £</option>
|
<option value="variance_value">Sort: Variance £</option>
|
||||||
<option value="variance_pct">Sort: Variance %</option>
|
<option value="variance_pct">Sort: Variance %</option>
|
||||||
<option value="name">Sort: Name</option>
|
<option value="name">Sort: Name</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main table */}
|
{/* Main table */}
|
||||||
<div style={styles.tableWrap}>
|
<div style={styles.tableWrap}>
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Ingredient</th>
|
<th style={styles.th}>Ingredient</th>
|
||||||
<th style={styles.th}>Category</th>
|
<th style={styles.th}>Category</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. Qty</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. Qty</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. £</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. £</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Actual Qty</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Actual Qty</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Actual £</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Actual £</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Variance £</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Variance £</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Variance %</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Variance %</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'center' }}>Dishes</th>
|
<th style={{ ...styles.th, textAlign: 'center' }}>Dishes</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'center' }}>Invoices</th>
|
<th style={{ ...styles.th, textAlign: 'center' }}>Invoices</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const bg = varianceBg(item.variance_pct, item.variance_value)
|
const bg = varianceBg(item.variance_pct, item.variance_value)
|
||||||
const vc = varianceColor(item.variance_pct, item.variance_value)
|
const vc = varianceColor(item.variance_pct, item.variance_value)
|
||||||
return (
|
return (
|
||||||
<tr key={item.ingredient_id} style={{ ...styles.tr, background: bg }}>
|
<tr key={item.ingredient_id} style={{ ...styles.tr, background: bg }}>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>
|
||||||
<span style={{ fontWeight: 600 }}>{item.ingredient_name}</span>
|
<span style={{ fontWeight: 600 }}>{item.ingredient_name}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, color: '#888', fontSize: '0.8rem' }}>
|
<td style={{ ...styles.td, color: '#888', fontSize: '0.8rem' }}>
|
||||||
{item.category || '—'}
|
{item.category || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.theoretical_qty > 0 ? fmtQty(item.theoretical_qty, item.standard_unit) : '—'}
|
{item.theoretical_qty > 0 ? fmtQty(item.theoretical_qty, item.standard_unit) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.theoretical_value > 0 ? fmt(item.theoretical_value) : '—'}
|
{item.theoretical_value > 0 ? fmt(item.theoretical_value) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}
|
<td style={{ ...styles.td, textAlign: 'right' }}
|
||||||
title={item.actual_qty == null ? 'No mapped purchases in period' : undefined}
|
title={item.actual_qty == null ? 'No mapped purchases in period' : undefined}
|
||||||
>
|
>
|
||||||
{item.actual_qty != null ? fmtQty(item.actual_qty, item.standard_unit) : '—'}
|
{item.actual_qty != null ? fmtQty(item.actual_qty, item.standard_unit) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>
|
<td style={{ ...styles.td, textAlign: 'right' }}>
|
||||||
{item.actual_value != null ? fmt(item.actual_value) : '—'}
|
{item.actual_value != null ? fmt(item.actual_value) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
|
||||||
{item.variance_value != null ? fmt(item.variance_value) : '—'}
|
{item.variance_value != null ? fmt(item.variance_value) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
|
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
|
||||||
{fmtPct(item.variance_pct)}
|
{fmtPct(item.variance_pct)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
|
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
|
||||||
{item.dishes_using}
|
{item.dishes_using}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
|
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
|
||||||
{item.invoice_count || '—'}
|
{item.invoice_count || '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<tr><td colSpan={10} style={{ ...styles.td, textAlign: 'center', color: '#999' }}>
|
<tr><td colSpan={10} style={{ ...styles.td, textAlign: 'center', color: '#999' }}>
|
||||||
{search || !showAll ? 'No items match filters.' : 'No data.'}
|
{search || !showAll ? 'No items match filters.' : 'No data.'}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unmapped sales */}
|
{/* Unmapped sales */}
|
||||||
{r.unmapped_sales.length > 0 && (
|
{r.unmapped_sales.length > 0 && (
|
||||||
<div style={styles.section}>
|
<div style={styles.section}>
|
||||||
<div
|
<div
|
||||||
style={styles.sectionHeader}
|
style={styles.sectionHeader}
|
||||||
onClick={() => setUnmappedCollapsed(!unmappedCollapsed)}
|
onClick={() => setUnmappedCollapsed(!unmappedCollapsed)}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span style={styles.collapseIcon}>{unmappedCollapsed ? '▸' : '▾'}</span>
|
<span style={styles.collapseIcon}>{unmappedCollapsed ? '▸' : '▾'}</span>
|
||||||
Unmapped Sales Items ({r.unmapped_sales.length}) — no recipe, can't calculate theoretical usage
|
Unmapped Sales Items ({r.unmapped_sales.length}) — no recipe, can't calculate theoretical usage
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!unmappedCollapsed && (
|
{!unmappedCollapsed && (
|
||||||
<table style={styles.table}>
|
<table style={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>Menu Item</th>
|
<th style={styles.th}>Menu Item</th>
|
||||||
<th style={styles.th}>Portion</th>
|
<th style={styles.th}>Portion</th>
|
||||||
<th style={styles.th}>Category</th>
|
<th style={styles.th}>Category</th>
|
||||||
<th style={{ ...styles.th, textAlign: 'right' }}>Qty Sold</th>
|
<th style={{ ...styles.th, textAlign: 'right' }}>Qty Sold</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{r.unmapped_sales.map((u, idx) => (
|
{r.unmapped_sales.map((u, idx) => (
|
||||||
<tr key={idx} style={styles.tr}>
|
<tr key={idx} style={styles.tr}>
|
||||||
<td style={styles.td}>{u.menu_item_name}</td>
|
<td style={styles.td}>{u.menu_item_name}</td>
|
||||||
<td style={{ ...styles.td, color: '#888' }}>
|
<td style={{ ...styles.td, color: '#888' }}>
|
||||||
{u.portion_name !== 'Normal' ? u.portion_name : '—'}
|
{u.portion_name !== 'Normal' ? u.portion_name : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...styles.td, color: '#888' }}>{u.category || '—'}</td>
|
<td style={{ ...styles.td, color: '#888' }}>{u.category || '—'}</td>
|
||||||
<td style={{ ...styles.td, textAlign: 'right' }}>{u.total_qty}</td>
|
<td style={{ ...styles.td, textAlign: 'right' }}>{u.total_qty}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Styles ============
|
// ============ Styles ============
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
container: { maxWidth: '1200px', margin: '0 auto', padding: '1.5rem' },
|
container: { maxWidth: '1200px', margin: '0 auto', padding: '1.5rem' },
|
||||||
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
|
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
|
||||||
|
|
||||||
// Date bar
|
// Date bar
|
||||||
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
|
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
|
||||||
dateLabel: { fontSize: '0.8rem', fontWeight: 600, color: '#666', display: 'flex', flexDirection: 'column' as const, gap: '0.25rem' },
|
dateLabel: { fontSize: '0.8rem', fontWeight: 600, color: '#666', display: 'flex', flexDirection: 'column' as const, gap: '0.25rem' },
|
||||||
dateInput: { padding: '0.4rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem' },
|
dateInput: { padding: '0.4rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem' },
|
||||||
generateBtn: {
|
generateBtn: {
|
||||||
padding: '0.45rem 1.2rem', background: '#e94560', color: 'white', border: 'none',
|
padding: '0.45rem 1.2rem', background: '#e94560', color: 'white', border: 'none',
|
||||||
borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem',
|
borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem',
|
||||||
},
|
},
|
||||||
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
|
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
|
||||||
|
|
||||||
// Summary bar
|
// Summary bar
|
||||||
summaryBar: {
|
summaryBar: {
|
||||||
background: '#1a1a2e', color: 'white', padding: '1.25rem', borderRadius: '10px', marginBottom: '1.5rem',
|
background: '#1a1a2e', color: 'white', padding: '1.25rem', borderRadius: '10px', marginBottom: '1.5rem',
|
||||||
},
|
},
|
||||||
summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' },
|
summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' },
|
||||||
summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const },
|
summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const },
|
||||||
summaryItem: {},
|
summaryItem: {},
|
||||||
summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 },
|
summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 },
|
||||||
summaryValue: { fontSize: '1.3rem', fontWeight: 700 },
|
summaryValue: { fontSize: '1.3rem', fontWeight: 700 },
|
||||||
summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' },
|
summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' },
|
||||||
countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
||||||
countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
||||||
countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' },
|
||||||
footnote: { fontSize: '0.72rem', color: '#777', marginTop: '0.75rem', fontStyle: 'italic' as const, lineHeight: 1.5 },
|
footnote: { fontSize: '0.72rem', color: '#777', marginTop: '0.75rem', fontStyle: 'italic' as const, lineHeight: 1.5 },
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
controlsBar: {
|
controlsBar: {
|
||||||
display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const,
|
display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const,
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
padding: '0.4rem 0.6rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem', width: '200px',
|
padding: '0.4rem 0.6rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem', width: '200px',
|
||||||
},
|
},
|
||||||
toggleLabel: { fontSize: '0.85rem', color: '#555', cursor: 'pointer', userSelect: 'none' as const },
|
toggleLabel: { fontSize: '0.85rem', color: '#555', cursor: 'pointer', userSelect: 'none' as const },
|
||||||
sortSelect: {
|
sortSelect: {
|
||||||
padding: '0.35rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.8rem', color: '#555',
|
padding: '0.35rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.8rem', color: '#555',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Table
|
// Table
|
||||||
tableWrap: { overflowX: 'auto' as const },
|
tableWrap: { overflowX: 'auto' as const },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const },
|
table: { width: '100%', borderCollapse: 'collapse' as const },
|
||||||
th: {
|
th: {
|
||||||
padding: '0.5rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0',
|
padding: '0.5rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0',
|
||||||
fontSize: '0.72rem', fontWeight: 600, color: '#666', background: '#fafafa',
|
fontSize: '0.72rem', fontWeight: 600, color: '#666', background: '#fafafa',
|
||||||
whiteSpace: 'nowrap' as const,
|
whiteSpace: 'nowrap' as const,
|
||||||
},
|
},
|
||||||
tr: { borderBottom: '1px solid #f0f0f0' },
|
tr: { borderBottom: '1px solid #f0f0f0' },
|
||||||
td: { padding: '0.4rem 0.75rem', fontSize: '0.83rem', whiteSpace: 'nowrap' as const },
|
td: { padding: '0.4rem 0.75rem', fontSize: '0.83rem', whiteSpace: 'nowrap' as const },
|
||||||
|
|
||||||
// Unmapped section
|
// Unmapped section
|
||||||
section: {
|
section: {
|
||||||
marginTop: '1.5rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden',
|
marginTop: '1.5rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden',
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer',
|
padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer',
|
||||||
fontWeight: 600, fontSize: '0.9rem', color: '#888',
|
fontWeight: 600, fontSize: '0.9rem', color: '#888',
|
||||||
},
|
},
|
||||||
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
|
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export default function BookingsStats() {
|
||||||
queryKey: ['resos-stats', submittedFromDate, submittedToDate],
|
queryKey: ['resos-stats', submittedFromDate, submittedToDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/resos/stats?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
const res = await fetch(`/kitchen/api/resos/stats?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch stats')
|
if (!res.ok) throw new Error('Failed to fetch stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -183,7 +183,7 @@ export default function BookingsStats() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const prevDates = getPreviousPeriodDates()
|
const prevDates = getPreviousPeriodDates()
|
||||||
const res = await fetch(`/kitchen/api/resos/stats?from_date=${prevDates.from}&to_date=${prevDates.to}`, {
|
const res = await fetch(`/kitchen/api/resos/stats?from_date=${prevDates.from}&to_date=${prevDates.to}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch previous stats')
|
if (!res.ok) throw new Error('Failed to fetch previous stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -196,7 +196,7 @@ export default function BookingsStats() {
|
||||||
queryKey: ['resos-bookings', selectedDate],
|
queryKey: ['resos-bookings', selectedDate],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
|
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch bookings')
|
if (!res.ok) throw new Error('Failed to fetch bookings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ export default function NewbookData() {
|
||||||
queryKey: ['settings'],
|
queryKey: ['settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch settings')
|
if (!res.ok) throw new Error('Failed to fetch settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const currencySymbol = settings?.currency_symbol || '£'
|
const currencySymbol = settings?.currency_symbol || '£'
|
||||||
|
|
@ -66,12 +66,12 @@ export default function NewbookData() {
|
||||||
queryKey: ['newbook-calendar', year, month],
|
queryKey: ['newbook-calendar', year, month],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/newbook/calendar/${year}/${month}`, {
|
const res = await fetch(`/kitchen/api/newbook/calendar/${year}/${month}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch calendar data')
|
if (!res.ok) throw new Error('Failed to fetch calendar data')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const goToPrevMonth = () => {
|
const goToPrevMonth = () => {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default function ResidentsTableChart() {
|
||||||
queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache
|
queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/residents-table-chart?start_date=${startDate}`, {
|
const res = await fetch(`/kitchen/api/residents-table-chart?start_date=${startDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch chart data')
|
if (!res.ok) throw new Error('Failed to fetch chart data')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,12 @@ export default function ResosData() {
|
||||||
queryKey: ['resos-settings'],
|
queryKey: ['resos-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/resos/settings', {
|
const res = await fetch('/kitchen/api/resos/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch settings')
|
if (!res.ok) throw new Error('Failed to fetch settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch daily stats for the month
|
// Fetch daily stats for the month
|
||||||
|
|
@ -133,12 +133,12 @@ export default function ResosData() {
|
||||||
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
|
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch daily stats')
|
if (!res.ok) throw new Error('Failed to fetch daily stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch previous month's stats for comparison
|
// Fetch previous month's stats for comparison
|
||||||
|
|
@ -152,12 +152,12 @@ export default function ResosData() {
|
||||||
const toDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
const toDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
|
const res = await fetch(`/kitchen/api/resos/daily-stats?from_date=${firstDay}&to_date=${toDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch previous month stats')
|
if (!res.ok) throw new Error('Failed to fetch previous month stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch calendar events for the month
|
// Fetch calendar events for the month
|
||||||
|
|
@ -168,7 +168,7 @@ export default function ResosData() {
|
||||||
const lastDay = new Date(year, month, 0)
|
const lastDay = new Date(year, month, 0)
|
||||||
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay.getDate()).padStart(2, '0')}`
|
||||||
const res = await fetch(`/kitchen/api/calendar-events/?from_date=${firstDay}&to_date=${toDate}`, {
|
const res = await fetch(`/kitchen/api/calendar-events/?from_date=${firstDay}&to_date=${toDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch calendar events')
|
if (!res.ok) throw new Error('Failed to fetch calendar events')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -182,7 +182,7 @@ export default function ResosData() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedDate) return []
|
if (!selectedDate) return []
|
||||||
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
|
const res = await fetch(`/kitchen/api/resos/bookings/${selectedDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch bookings')
|
if (!res.ok) throw new Error('Failed to fetch bookings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -195,7 +195,7 @@ export default function ResosData() {
|
||||||
queryKey: ['resos-all-opening-hours'],
|
queryKey: ['resos-all-opening-hours'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/kitchen/api/resos/opening-hours`, {
|
const res = await fetch(`/kitchen/api/resos/opening-hours`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch opening hours')
|
if (!res.ok) throw new Error('Failed to fetch opening hours')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -231,7 +231,7 @@ export default function ResosData() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedDate) return []
|
if (!selectedDate) return []
|
||||||
const res = await fetch(`/kitchen/api/resos/opening-hours/${selectedDate}`, {
|
const res = await fetch(`/kitchen/api/resos/opening-hours/${selectedDate}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch opening hours')
|
if (!res.ok) throw new Error('Failed to fetch opening hours')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1324,7 +1324,7 @@ export default function ResosData() {
|
||||||
if (confirm('Delete this event?')) {
|
if (confirm('Delete this event?')) {
|
||||||
await fetch(`/kitchen/api/calendar-events/${editingEvent.id}`, {
|
await fetch(`/kitchen/api/calendar-events/${editingEvent.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
setShowEventModal(false)
|
setShowEventModal(false)
|
||||||
refetchEvents()
|
refetchEvents()
|
||||||
|
|
@ -1365,8 +1365,8 @@ export default function ResosData() {
|
||||||
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(eventForm)
|
body: JSON.stringify(eventForm)
|
||||||
|
|
|
||||||
|
|
@ -536,7 +536,7 @@ export default function Settings() {
|
||||||
queryKey: ['settings'],
|
queryKey: ['settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch settings')
|
if (!res.ok) throw new Error('Failed to fetch settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -548,7 +548,7 @@ export default function Settings() {
|
||||||
queryKey: ['newbook-settings'],
|
queryKey: ['newbook-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/settings', {
|
const res = await fetch('/kitchen/api/newbook/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text()
|
const errorText = await res.text()
|
||||||
|
|
@ -567,7 +567,7 @@ export default function Settings() {
|
||||||
queryKey: ['resos-settings'],
|
queryKey: ['resos-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/resos/settings', {
|
const res = await fetch('/kitchen/api/resos/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text()
|
const errorText = await res.text()
|
||||||
|
|
@ -585,7 +585,7 @@ export default function Settings() {
|
||||||
queryKey: ['gl-accounts'],
|
queryKey: ['gl-accounts'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/gl-accounts', {
|
const res = await fetch('/kitchen/api/newbook/gl-accounts', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -597,7 +597,7 @@ export default function Settings() {
|
||||||
queryKey: ['room-categories'],
|
queryKey: ['room-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/room-categories', {
|
const res = await fetch('/kitchen/api/newbook/room-categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -610,12 +610,12 @@ export default function Settings() {
|
||||||
queryKey: ['sambapos-settings'],
|
queryKey: ['sambapos-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/settings', {
|
const res = await fetch('/kitchen/api/sambapos/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch SambaPOS settings')
|
if (!res.ok) throw new Error('Failed to fetch SambaPOS settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch SambaPOS categories (only when connection is configured)
|
// Fetch SambaPOS categories (only when connection is configured)
|
||||||
|
|
@ -623,7 +623,7 @@ export default function Settings() {
|
||||||
queryKey: ['sambapos-categories'],
|
queryKey: ['sambapos-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/categories', {
|
const res = await fetch('/kitchen/api/sambapos/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -640,7 +640,7 @@ export default function Settings() {
|
||||||
queryKey: ['sambapos-group-codes'],
|
queryKey: ['sambapos-group-codes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/group-codes', {
|
const res = await fetch('/kitchen/api/sambapos/group-codes', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -657,7 +657,7 @@ export default function Settings() {
|
||||||
queryKey: ['sambapos-gl-codes'],
|
queryKey: ['sambapos-gl-codes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/gl-codes', {
|
const res = await fetch('/kitchen/api/sambapos/gl-codes', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -674,12 +674,12 @@ export default function Settings() {
|
||||||
queryKey: ['sambapos-selected-gl-codes'],
|
queryKey: ['sambapos-selected-gl-codes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/gl-codes/selected', {
|
const res = await fetch('/kitchen/api/sambapos/gl-codes/selected', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch selected GL codes')
|
if (!res.ok) throw new Error('Failed to fetch selected GL codes')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch KDS settings
|
// Fetch KDS settings
|
||||||
|
|
@ -687,12 +687,12 @@ export default function Settings() {
|
||||||
queryKey: ['kds-settings'],
|
queryKey: ['kds-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/kds/settings', {
|
const res = await fetch('/kitchen/api/kds/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch KDS settings')
|
if (!res.ok) throw new Error('Failed to fetch KDS settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Kitchen Details
|
// Fetch Kitchen Details
|
||||||
|
|
@ -710,12 +710,12 @@ export default function Settings() {
|
||||||
queryKey: ['kitchen-details'],
|
queryKey: ['kitchen-details'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/kitchen-details', {
|
const res = await fetch('/kitchen/api/settings/kitchen-details', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch kitchen details')
|
if (!res.ok) throw new Error('Failed to fetch kitchen details')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Budget settings
|
// Fetch Budget settings
|
||||||
|
|
@ -730,12 +730,12 @@ export default function Settings() {
|
||||||
queryKey: ['budget-settings'],
|
queryKey: ['budget-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/budget/settings', {
|
const res = await fetch('/kitchen/api/budget/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch Budget settings')
|
if (!res.ok) throw new Error('Failed to fetch Budget settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Nextcloud settings
|
// Fetch Nextcloud settings
|
||||||
|
|
@ -743,12 +743,12 @@ export default function Settings() {
|
||||||
queryKey: ['nextcloud-settings'],
|
queryKey: ['nextcloud-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/nextcloud', {
|
const res = await fetch('/kitchen/api/settings/nextcloud', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch Nextcloud settings')
|
if (!res.ok) throw new Error('Failed to fetch Nextcloud settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch which integrations are configured in the central stack settings service
|
// Fetch which integrations are configured in the central stack settings service
|
||||||
|
|
@ -756,12 +756,12 @@ export default function Settings() {
|
||||||
queryKey: ['global-integration-status'],
|
queryKey: ['global-integration-status'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/global-status', {
|
const res = await fetch('/kitchen/api/settings/global-status', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return {}
|
if (!res.ok) return {}
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -770,12 +770,12 @@ export default function Settings() {
|
||||||
queryKey: ['nextcloud-stats'],
|
queryKey: ['nextcloud-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/nextcloud/stats', {
|
const res = await fetch('/kitchen/api/settings/nextcloud/stats', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch Nextcloud stats')
|
if (!res.ok) throw new Error('Failed to fetch Nextcloud stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Backup settings
|
// Fetch Backup settings
|
||||||
|
|
@ -783,12 +783,12 @@ export default function Settings() {
|
||||||
queryKey: ['backup-settings'],
|
queryKey: ['backup-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/backup/settings', {
|
const res = await fetch('/kitchen/api/backup/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch backup settings')
|
if (!res.ok) throw new Error('Failed to fetch backup settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Backup history
|
// Fetch Backup history
|
||||||
|
|
@ -796,12 +796,12 @@ export default function Settings() {
|
||||||
queryKey: ['backup-history'],
|
queryKey: ['backup-history'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/backup/history', {
|
const res = await fetch('/kitchen/api/backup/history', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch backup history')
|
if (!res.ok) throw new Error('Failed to fetch backup history')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch Search settings
|
// Fetch Search settings
|
||||||
|
|
@ -809,12 +809,12 @@ export default function Settings() {
|
||||||
queryKey: ['search-settings'],
|
queryKey: ['search-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/search/settings', {
|
const res = await fetch('/kitchen/api/search/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch search settings')
|
if (!res.ok) throw new Error('Failed to fetch search settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch IMAP settings
|
// Fetch IMAP settings
|
||||||
|
|
@ -822,7 +822,7 @@ export default function Settings() {
|
||||||
queryKey: ['imap-settings'],
|
queryKey: ['imap-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/imap/settings', {
|
const res = await fetch('/kitchen/api/imap/settings', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch IMAP settings')
|
if (!res.ok) throw new Error('Failed to fetch IMAP settings')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -835,7 +835,7 @@ export default function Settings() {
|
||||||
queryKey: ['imap-logs'],
|
queryKey: ['imap-logs'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/imap/logs?limit=20', {
|
const res = await fetch('/kitchen/api/imap/logs?limit=20', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch IMAP logs')
|
if (!res.ok) throw new Error('Failed to fetch IMAP logs')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -848,7 +848,7 @@ export default function Settings() {
|
||||||
queryKey: ['imap-stats'],
|
queryKey: ['imap-stats'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/imap/logs/stats', {
|
const res = await fetch('/kitchen/api/imap/logs/stats', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch IMAP stats')
|
if (!res.ok) throw new Error('Failed to fetch IMAP stats')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1053,7 +1053,7 @@ export default function Settings() {
|
||||||
// Auto-fetch custom fields if mapping exists but fields list is empty
|
// Auto-fetch custom fields if mapping exists but fields list is empty
|
||||||
if (resosSettings.resos_custom_field_mapping && Object.keys(resosSettings.resos_custom_field_mapping).length > 0 && customFields.length === 0) {
|
if (resosSettings.resos_custom_field_mapping && Object.keys(resosSettings.resos_custom_field_mapping).length > 0 && customFields.length === 0) {
|
||||||
fetch('/kitchen/api/resos/custom-fields', {
|
fetch('/kitchen/api/resos/custom-fields', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setCustomFields(data.custom_fields || []))
|
.then(data => setCustomFields(data.custom_fields || []))
|
||||||
|
|
@ -1063,7 +1063,7 @@ export default function Settings() {
|
||||||
// Auto-fetch opening hours if mapping exists but hours list is empty
|
// Auto-fetch opening hours if mapping exists but hours list is empty
|
||||||
if (resosSettings.resos_opening_hours_mapping && resosSettings.resos_opening_hours_mapping.length > 0 && openingHours.length === 0) {
|
if (resosSettings.resos_opening_hours_mapping && resosSettings.resos_opening_hours_mapping.length > 0 && openingHours.length === 0) {
|
||||||
fetch('/kitchen/api/resos/opening-hours', {
|
fetch('/kitchen/api/resos/opening-hours', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setOpeningHours(data.opening_hours || []))
|
.then(data => setOpeningHours(data.opening_hours || []))
|
||||||
|
|
@ -1077,7 +1077,7 @@ export default function Settings() {
|
||||||
queryKey: ['food-flag-categories'],
|
queryKey: ['food-flag-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/categories', {
|
const res = await fetch('/kitchen/api/food-flags/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1089,7 +1089,7 @@ export default function Settings() {
|
||||||
queryKey: ['api-access-settings'],
|
queryKey: ['api-access-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/api-access', {
|
const res = await fetch('/kitchen/api/settings/api-access', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { api_key: null, api_key_enabled: false }
|
if (!res.ok) return { api_key: null, api_key_enabled: false }
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1109,7 +1109,7 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { name: string; propagation_type: string }) => {
|
mutationFn: async (data: { name: string; propagation_type: string }) => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/categories', {
|
const res = await fetch('/kitchen/api/food-flags/categories', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to create category')
|
if (!res.ok) throw new Error('Failed to create category')
|
||||||
|
|
@ -1130,7 +1130,7 @@ export default function Settings() {
|
||||||
mutationFn: async ({ id, data }: { id: number; data: { name?: string; propagation_type?: string; required?: boolean } }) => {
|
mutationFn: async ({ id, data }: { id: number; data: { name?: string; propagation_type?: string; required?: boolean } }) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
|
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update category')
|
if (!res.ok) throw new Error('Failed to update category')
|
||||||
|
|
@ -1149,7 +1149,7 @@ export default function Settings() {
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
|
const res = await fetch(`/kitchen/api/food-flags/categories/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete category')
|
if (!res.ok) throw new Error('Failed to delete category')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1166,7 +1166,7 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { category_id: number; name: string; code?: string; icon?: string }) => {
|
mutationFn: async (data: { category_id: number; name: string; code?: string; icon?: string }) => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/flags', {
|
const res = await fetch('/kitchen/api/food-flags/flags', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to create flag')
|
if (!res.ok) throw new Error('Failed to create flag')
|
||||||
|
|
@ -1188,7 +1188,7 @@ export default function Settings() {
|
||||||
mutationFn: async ({ id, data }: { id: number; data: { name?: string; code?: string; icon?: string } }) => {
|
mutationFn: async ({ id, data }: { id: number; data: { name?: string; code?: string; icon?: string } }) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
|
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to update flag')
|
if (!res.ok) throw new Error('Failed to update flag')
|
||||||
|
|
@ -1207,7 +1207,7 @@ export default function Settings() {
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
|
const res = await fetch(`/kitchen/api/food-flags/flags/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete flag')
|
if (!res.ok) throw new Error('Failed to delete flag')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1238,7 +1238,7 @@ export default function Settings() {
|
||||||
queryKey: ['allergen-keywords'],
|
queryKey: ['allergen-keywords'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/keywords', {
|
const res = await fetch('/kitchen/api/food-flags/keywords', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1250,7 +1250,7 @@ export default function Settings() {
|
||||||
mutationFn: async ({ food_flag_id, keyword }: { food_flag_id: number; keyword: string }) => {
|
mutationFn: async ({ food_flag_id, keyword }: { food_flag_id: number; keyword: string }) => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/keywords', {
|
const res = await fetch('/kitchen/api/food-flags/keywords', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ food_flag_id, keyword }),
|
body: JSON.stringify({ food_flag_id, keyword }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -1275,7 +1275,7 @@ export default function Settings() {
|
||||||
mutationFn: async (keywordId: number) => {
|
mutationFn: async (keywordId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/food-flags/keywords/${keywordId}`, {
|
const res = await fetch(`/kitchen/api/food-flags/keywords/${keywordId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete keyword')
|
if (!res.ok) throw new Error('Failed to delete keyword')
|
||||||
},
|
},
|
||||||
|
|
@ -1288,7 +1288,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/food-flags/keywords/reset-defaults', {
|
const res = await fetch('/kitchen/api/food-flags/keywords/reset-defaults', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to reset keywords')
|
if (!res.ok) throw new Error('Failed to reset keywords')
|
||||||
},
|
},
|
||||||
|
|
@ -1311,7 +1311,7 @@ export default function Settings() {
|
||||||
queryKey: ['ingredient-categories-settings'],
|
queryKey: ['ingredient-categories-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/ingredients/categories', {
|
const res = await fetch('/kitchen/api/ingredients/categories', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1323,7 +1323,7 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { name: string; sort_order: number }) => {
|
mutationFn: async (data: { name: string; sort_order: number }) => {
|
||||||
const res = await fetch('/kitchen/api/ingredients/categories', {
|
const res = await fetch('/kitchen/api/ingredients/categories', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to create category') }
|
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to create category') }
|
||||||
|
|
@ -1344,7 +1344,7 @@ export default function Settings() {
|
||||||
mutationFn: async ({ id, data }: { id: number; data: { name?: string; sort_order?: number } }) => {
|
mutationFn: async ({ id, data }: { id: number; data: { name?: string; sort_order?: number } }) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
|
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to update category') }
|
if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.detail || 'Failed to update category') }
|
||||||
|
|
@ -1363,7 +1363,7 @@ export default function Settings() {
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
|
const res = await fetch(`/kitchen/api/ingredients/categories/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete category')
|
if (!res.ok) throw new Error('Failed to delete category')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1393,7 +1393,7 @@ export default function Settings() {
|
||||||
queryKey: ['recipe-sections-settings'],
|
queryKey: ['recipe-sections-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=recipe', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=recipe', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1406,7 +1406,7 @@ export default function Settings() {
|
||||||
queryKey: ['dish-courses-settings'],
|
queryKey: ['dish-courses-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=dish', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections?section_type=dish', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1419,7 +1419,7 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { api_key_enabled: boolean }) => {
|
mutationFn: async (data: { api_key_enabled: boolean }) => {
|
||||||
const res = await fetch('/kitchen/api/settings/api-access', {
|
const res = await fetch('/kitchen/api/settings/api-access', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to save API access settings')
|
if (!res.ok) throw new Error('Failed to save API access settings')
|
||||||
|
|
@ -1438,7 +1438,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/api-access/regenerate', {
|
const res = await fetch('/kitchen/api/settings/api-access/regenerate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to regenerate API key')
|
if (!res.ok) throw new Error('Failed to regenerate API key')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1462,7 +1462,7 @@ export default function Settings() {
|
||||||
queryKey: ['llm-usage'],
|
queryKey: ['llm-usage'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/llm-usage', {
|
const res = await fetch('/kitchen/api/settings/llm-usage', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { total_calls: 0, successful_calls: 0, failed_calls: 0, total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0, cache_entries_this_month: 0 }
|
if (!res.ok) return { total_calls: 0, successful_calls: 0, failed_calls: 0, total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0, cache_entries_this_month: 0 }
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1480,7 +1480,7 @@ export default function Settings() {
|
||||||
queryKey: ['llm-models'],
|
queryKey: ['llm-models'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/llm-models', {
|
const res = await fetch('/kitchen/api/settings/llm-models', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) return { models: [], default: 'claude-haiku-4-5-20251001', current: 'claude-haiku-4-5-20251001' }
|
if (!res.ok) return { models: [], default: 'claude-haiku-4-5-20251001', current: 'claude-haiku-4-5-20251001' }
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -1493,7 +1493,7 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to save LLM settings')
|
if (!res.ok) throw new Error('Failed to save LLM settings')
|
||||||
|
|
@ -1512,7 +1512,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/test-llm', {
|
const res = await fetch('/kitchen/api/settings/test-llm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1548,8 +1548,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Partial<SettingsData & { azure_key?: string }>) => {
|
mutationFn: async (data: Partial<SettingsData & { azure_key?: string }>) => {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1572,7 +1572,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/test-azure', {
|
const res = await fetch('/kitchen/api/settings/test-azure', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1593,8 +1593,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { current_password: string; new_password: string }) => {
|
mutationFn: async (data: { current_password: string; new_password: string }) => {
|
||||||
const res = await fetch('/auth/change-password', {
|
const res = await fetch('/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1621,7 +1621,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/invoices/reprocess-all', {
|
const res = await fetch('/kitchen/api/invoices/reprocess-all', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1643,7 +1643,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes/cleanup-false-price-changes', {
|
const res = await fetch('/kitchen/api/recipes/cleanup-false-price-changes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1664,7 +1664,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/recipes/backfill-invoice-references', {
|
const res = await fetch('/kitchen/api/recipes/backfill-invoice-references', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1685,7 +1685,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/suppliers/rematch-fuzzy', {
|
const res = await fetch('/kitchen/api/suppliers/rematch-fuzzy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1709,8 +1709,8 @@ export default function Settings() {
|
||||||
console.log('[Newbook] Sending PATCH with data:', data)
|
console.log('[Newbook] Sending PATCH with data:', data)
|
||||||
const res = await fetch('/kitchen/api/newbook/settings', {
|
const res = await fetch('/kitchen/api/newbook/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1741,7 +1741,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/test-connection', {
|
const res = await fetch('/kitchen/api/newbook/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1764,8 +1764,8 @@ export default function Settings() {
|
||||||
console.log('[Resos] Sending PATCH with data:', data)
|
console.log('[Resos] Sending PATCH with data:', data)
|
||||||
const res = await fetch('/kitchen/api/resos/settings', {
|
const res = await fetch('/kitchen/api/resos/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1794,7 +1794,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/gl-accounts/fetch', {
|
const res = await fetch('/kitchen/api/newbook/gl-accounts/fetch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1816,7 +1816,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/sync/forecast', {
|
const res = await fetch('/kitchen/api/newbook/sync/forecast', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1838,8 +1838,8 @@ export default function Settings() {
|
||||||
mutationFn: async (dates: { date_from: string; date_to: string }) => {
|
mutationFn: async (dates: { date_from: string; date_to: string }) => {
|
||||||
const res = await fetch('/kitchen/api/newbook/sync/historical', {
|
const res = await fetch('/kitchen/api/newbook/sync/historical', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(dates),
|
body: JSON.stringify(dates),
|
||||||
|
|
@ -1865,8 +1865,8 @@ export default function Settings() {
|
||||||
mutationFn: async ({ id, is_tracked }: { id: number; is_tracked: boolean }) => {
|
mutationFn: async ({ id, is_tracked }: { id: number; is_tracked: boolean }) => {
|
||||||
const res = await fetch(`/kitchen/api/newbook/gl-accounts/${id}`, {
|
const res = await fetch(`/kitchen/api/newbook/gl-accounts/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ is_tracked }),
|
body: JSON.stringify({ is_tracked }),
|
||||||
|
|
@ -1883,8 +1883,8 @@ export default function Settings() {
|
||||||
mutationFn: async (updates: { id: number; is_tracked: boolean }[]) => {
|
mutationFn: async (updates: { id: number; is_tracked: boolean }[]) => {
|
||||||
const res = await fetch('/kitchen/api/newbook/gl-accounts/bulk-update', {
|
const res = await fetch('/kitchen/api/newbook/gl-accounts/bulk-update', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ updates }),
|
body: JSON.stringify({ updates }),
|
||||||
|
|
@ -1902,7 +1902,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/newbook/room-categories/fetch', {
|
const res = await fetch('/kitchen/api/newbook/room-categories/fetch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1924,8 +1924,8 @@ export default function Settings() {
|
||||||
mutationFn: async (updates: { id: number; is_included: boolean }[]) => {
|
mutationFn: async (updates: { id: number; is_included: boolean }[]) => {
|
||||||
const res = await fetch('/kitchen/api/newbook/room-categories/bulk-update', {
|
const res = await fetch('/kitchen/api/newbook/room-categories/bulk-update', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ updates }),
|
body: JSON.stringify({ updates }),
|
||||||
|
|
@ -1943,8 +1943,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/settings', {
|
const res = await fetch('/kitchen/api/sambapos/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -1967,7 +1967,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/test-connection', {
|
const res = await fetch('/kitchen/api/sambapos/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -1989,8 +1989,8 @@ export default function Settings() {
|
||||||
mutationFn: async (courses: string[]) => {
|
mutationFn: async (courses: string[]) => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/tracked-categories', {
|
const res = await fetch('/kitchen/api/sambapos/tracked-categories', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ categories: courses }),
|
body: JSON.stringify({ categories: courses }),
|
||||||
|
|
@ -2012,8 +2012,8 @@ export default function Settings() {
|
||||||
mutationFn: async (items: string[]) => {
|
mutationFn: async (items: string[]) => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/excluded-items', {
|
const res = await fetch('/kitchen/api/sambapos/excluded-items', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ items }),
|
||||||
|
|
@ -2035,8 +2035,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: { food_codes: string[]; beverage_codes: string[] }) => {
|
mutationFn: async (data: { food_codes: string[]; beverage_codes: string[] }) => {
|
||||||
const res = await fetch('/kitchen/api/sambapos/gl-codes', {
|
const res = await fetch('/kitchen/api/sambapos/gl-codes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2059,8 +2059,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/kds/settings', {
|
const res = await fetch('/kitchen/api/kds/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2082,7 +2082,7 @@ export default function Settings() {
|
||||||
const kdsTestMutation = useMutation({
|
const kdsTestMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/kds/test-connection', {
|
const res = await fetch('/kitchen/api/kds/test-connection', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
|
@ -2104,8 +2104,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/settings/kitchen-details', {
|
const res = await fetch('/kitchen/api/settings/kitchen-details', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2130,8 +2130,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/budget/settings', {
|
const res = await fetch('/kitchen/api/budget/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2157,7 +2157,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/budget/test-forecast-connection', {
|
const res = await fetch('/kitchen/api/budget/test-forecast-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
|
@ -2179,8 +2179,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/settings/nextcloud', {
|
const res = await fetch('/kitchen/api/settings/nextcloud', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2206,7 +2206,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/nextcloud/test', {
|
const res = await fetch('/kitchen/api/settings/nextcloud/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
|
|
@ -2227,7 +2227,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/settings/nextcloud/archive-all', {
|
const res = await fetch('/kitchen/api/settings/nextcloud/archive-all', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
|
|
@ -2250,8 +2250,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/backup/settings', {
|
const res = await fetch('/kitchen/api/backup/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -2276,7 +2276,7 @@ export default function Settings() {
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch('/kitchen/api/backup/create', {
|
const res = await fetch('/kitchen/api/backup/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
|
|
@ -2299,7 +2299,7 @@ export default function Settings() {
|
||||||
mutationFn: async (backupId: number) => {
|
mutationFn: async (backupId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/backup/${backupId}/restore`, {
|
const res = await fetch(`/kitchen/api/backup/${backupId}/restore`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
|
|
@ -2322,7 +2322,7 @@ export default function Settings() {
|
||||||
mutationFn: async (backupId: number) => {
|
mutationFn: async (backupId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/backup/${backupId}`, {
|
const res = await fetch(`/kitchen/api/backup/${backupId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
|
|
@ -2346,7 +2346,7 @@ export default function Settings() {
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
const res = await fetch('/kitchen/api/backup/upload', {
|
const res = await fetch('/kitchen/api/backup/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -2370,8 +2370,8 @@ export default function Settings() {
|
||||||
mutationFn: async (data: Record<string, unknown>) => {
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
const res = await fetch('/kitchen/api/search/settings', {
|
const res = await fetch('/kitchen/api/search/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
@ -3135,9 +3135,9 @@ export default function Settings() {
|
||||||
|
|
||||||
const saveRes = await fetch('/kitchen/api/settings/', {
|
const saveRes = await fetch('/kitchen/api/settings/', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(savePayload)
|
body: JSON.stringify(savePayload)
|
||||||
})
|
})
|
||||||
|
|
@ -3153,7 +3153,7 @@ export default function Settings() {
|
||||||
setSmtpTestStatus('Testing connection...')
|
setSmtpTestStatus('Testing connection...')
|
||||||
const res = await fetch('/kitchen/api/settings/test-smtp', {
|
const res = await fetch('/kitchen/api/settings/test-smtp', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -3178,9 +3178,9 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
smtp_host: smtpHost || null,
|
smtp_host: smtpHost || null,
|
||||||
|
|
@ -3355,9 +3355,9 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/imap/test-connection', {
|
const res = await fetch('/kitchen/api/imap/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
imap_host: imapHost || undefined,
|
imap_host: imapHost || undefined,
|
||||||
|
|
@ -3388,7 +3388,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/imap/sync-now', {
|
const res = await fetch('/kitchen/api/imap/sync-now', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|
@ -3418,9 +3418,9 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/imap/settings', {
|
const res = await fetch('/kitchen/api/imap/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
imap_host: imapHost || null,
|
imap_host: imapHost || null,
|
||||||
|
|
@ -3624,9 +3624,9 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/settings/', {
|
const res = await fetch('/kitchen/api/settings/', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
dext_email: dextEmail || null,
|
dext_email: dextEmail || null,
|
||||||
|
|
@ -3675,7 +3675,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/invoices/bulk/mark-all-dext-sent', {
|
const res = await fetch('/kitchen/api/invoices/bulk/mark-all-dext-sent', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
|
|
@ -4073,7 +4073,7 @@ export default function Settings() {
|
||||||
setResosTestStatus('Testing...')
|
setResosTestStatus('Testing...')
|
||||||
const res = await fetch('/kitchen/api/resos/test-connection', {
|
const res = await fetch('/kitchen/api/resos/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setResosTestStatus('✓ Connection successful')
|
setResosTestStatus('✓ Connection successful')
|
||||||
|
|
@ -4142,7 +4142,7 @@ export default function Settings() {
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/resos/custom-fields', {
|
const res = await fetch('/kitchen/api/resos/custom-fields', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -4207,7 +4207,7 @@ export default function Settings() {
|
||||||
// First, sync opening hours to database (POST endpoint)
|
// First, sync opening hours to database (POST endpoint)
|
||||||
const syncRes = await fetch('/kitchen/api/resos/sync/opening-hours', {
|
const syncRes = await fetch('/kitchen/api/resos/sync/opening-hours', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!syncRes.ok) {
|
if (!syncRes.ok) {
|
||||||
|
|
@ -4220,7 +4220,7 @@ export default function Settings() {
|
||||||
|
|
||||||
// Then, fetch opening hours for display (GET endpoint)
|
// Then, fetch opening hours for display (GET endpoint)
|
||||||
const res = await fetch('/kitchen/api/resos/opening-hours', {
|
const res = await fetch('/kitchen/api/resos/opening-hours', {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -4531,7 +4531,7 @@ export default function Settings() {
|
||||||
setResosSaveMessage('Syncing upcoming bookings...')
|
setResosSaveMessage('Syncing upcoming bookings...')
|
||||||
const res = await fetch('/kitchen/api/resos/sync/upcoming', {
|
const res = await fetch('/kitchen/api/resos/sync/upcoming', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -4559,7 +4559,7 @@ export default function Settings() {
|
||||||
setResosSaveMessage('Syncing forecast...')
|
setResosSaveMessage('Syncing forecast...')
|
||||||
const res = await fetch('/kitchen/api/resos/sync/forecast', {
|
const res = await fetch('/kitchen/api/resos/sync/forecast', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -4599,8 +4599,8 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/resos/settings', {
|
const res = await fetch('/kitchen/api/resos/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -4682,7 +4682,7 @@ export default function Settings() {
|
||||||
setResosSaveMessage('Syncing historical data...')
|
setResosSaveMessage('Syncing historical data...')
|
||||||
const res = await fetch(`/kitchen/api/resos/sync/historical?from_date=${historicalResosDateFrom}&to_date=${historicalResosDateTo}`, {
|
const res = await fetch(`/kitchen/api/resos/sync/historical?from_date=${historicalResosDateFrom}&to_date=${historicalResosDateTo}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -6188,7 +6188,7 @@ export default function Settings() {
|
||||||
<div style={styles.actionButtons}>
|
<div style={styles.actionButtons}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(`/kitchen/api/backup/${backup.id}/download?token=${token}`, '_blank')
|
window.open(`/kitchen/api/backup/${backup.id}/download`, '_blank')
|
||||||
}}
|
}}
|
||||||
style={styles.actionBtn}
|
style={styles.actionBtn}
|
||||||
disabled={backup.status !== 'success'}
|
disabled={backup.status !== 'success'}
|
||||||
|
|
@ -6249,7 +6249,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/food-flags/seed-defaults', {
|
const res = await fetch('/kitchen/api/food-flags/seed-defaults', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -6676,7 +6676,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/ingredients/categories/seed-defaults', {
|
const res = await fetch('/kitchen/api/ingredients/categories/seed-defaults', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -6846,7 +6846,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=recipe', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=recipe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -6891,7 +6891,7 @@ export default function Settings() {
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirm(`Delete "${sec.name}"?${sec.recipe_count > 0 ? ` ${sec.recipe_count} recipe(s) will become unsectioned.` : ''}`)) {
|
if (confirm(`Delete "${sec.name}"?${sec.recipe_count > 0 ? ` ${sec.recipe_count} recipe(s) will become unsectioned.` : ''}`)) {
|
||||||
const res = await fetch(`/kitchen/api/recipes/menu-sections/${sec.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch(`/kitchen/api/recipes/menu-sections/${sec.id}`, { method: 'DELETE', credentials: 'include' })
|
||||||
if (res.ok) refetchRecipeSections()
|
if (res.ok) refetchRecipeSections()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -6921,7 +6921,7 @@ export default function Settings() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
|
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
|
||||||
})
|
})
|
||||||
if (res.ok) { input.value = ''; refetchRecipeSections() }
|
if (res.ok) { input.value = ''; refetchRecipeSections() }
|
||||||
|
|
@ -6935,7 +6935,7 @@ export default function Settings() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
|
body: JSON.stringify({ name, section_type: 'recipe', sort_order: recipeSections?.length || 0 }),
|
||||||
})
|
})
|
||||||
if (res.ok) { input.value = ''; refetchRecipeSections() }
|
if (res.ok) { input.value = ''; refetchRecipeSections() }
|
||||||
|
|
@ -6957,7 +6957,7 @@ export default function Settings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=dish', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections/seed-defaults?section_type=dish', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
@ -7002,7 +7002,7 @@ export default function Settings() {
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirm(`Delete "${course.name}"?${course.recipe_count > 0 ? ` ${course.recipe_count} dish(es) will become uncategorised.` : ''}`)) {
|
if (confirm(`Delete "${course.name}"?${course.recipe_count > 0 ? ` ${course.recipe_count} dish(es) will become uncategorised.` : ''}`)) {
|
||||||
const res = await fetch(`/kitchen/api/recipes/menu-sections/${course.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } })
|
const res = await fetch(`/kitchen/api/recipes/menu-sections/${course.id}`, { method: 'DELETE', credentials: 'include' })
|
||||||
if (res.ok) refetchDishCourses()
|
if (res.ok) refetchDishCourses()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -7032,7 +7032,7 @@ export default function Settings() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
|
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
|
||||||
})
|
})
|
||||||
if (res.ok) { input.value = ''; refetchDishCourses() }
|
if (res.ok) { input.value = ''; refetchDishCourses() }
|
||||||
|
|
@ -7046,7 +7046,7 @@ export default function Settings() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
const res = await fetch('/kitchen/api/recipes/menu-sections', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
|
body: JSON.stringify({ name, section_type: 'dish', sort_order: dishCourses?.length || 0 }),
|
||||||
})
|
})
|
||||||
if (res.ok) { input.value = ''; refetchDishCourses() }
|
if (res.ok) { input.value = ''; refetchDishCourses() }
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -121,12 +121,12 @@ export default function WastageLogbook() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = queryString ? `/kitchen/api/logbook?${queryString}` : '/kitchen/api/logbook'
|
const url = queryString ? `/kitchen/api/logbook?${queryString}` : '/kitchen/api/logbook'
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch logbook entries')
|
if (!res.ok) throw new Error('Failed to fetch logbook entries')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch summary
|
// Fetch summary
|
||||||
|
|
@ -140,19 +140,19 @@ export default function WastageLogbook() {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = summaryString ? `/kitchen/api/logbook/summary?${summaryString}` : '/kitchen/api/logbook/summary'
|
const url = summaryString ? `/kitchen/api/logbook/summary?${summaryString}` : '/kitchen/api/logbook/summary'
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to fetch summary')
|
if (!res.ok) throw new Error('Failed to fetch summary')
|
||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
enabled: !!token,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (entryId: number) => {
|
mutationFn: async (entryId: number) => {
|
||||||
const res = await fetch(`/kitchen/api/logbook/${entryId}`, {
|
const res = await fetch(`/kitchen/api/logbook/${entryId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Failed to delete entry')
|
if (!res.ok) throw new Error('Failed to delete entry')
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -571,7 +571,7 @@ function CreateEntryModal({
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/kitchen/api/logbook/products/search?query=${encodeURIComponent(query)}`, {
|
const res = await fetch(`/kitchen/api/logbook/products/search?query=${encodeURIComponent(query)}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -662,9 +662,9 @@ function CreateEntryModal({
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue