Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup

- Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth)
- Removed ?token= from all file/image URLs (browser history exposure)
- Added ProtectedRoute wrapper to all capability-gated routes in App.tsx
- OCR background task: added transaction boundary, improved error handling and status rollback
- DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing
- File upload: commit DB row before writing to disk to prevent orphaned files
- GP% clamped to 100% in GPReport (credit notes can inflate above 100%)
- Added CSV export to GPReport (suppliers, daily data, allowances breakdown)
- Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility
- DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py
- N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N)
- Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution
- Budget spend rate input: rejects negative values
- GPReport allowances toggle: persisted to localStorage across page loads
- DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars
- Fixed SyntaxWarning from \\d in invoices.py docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-13 10:04:20 +00:00
parent 6f6e16c88f
commit ba075276b1
57 changed files with 15427 additions and 15274 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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