Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup
- Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth) - Removed ?token= from all file/image URLs (browser history exposure) - Added ProtectedRoute wrapper to all capability-gated routes in App.tsx - OCR background task: added transaction boundary, improved error handling and status rollback - DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing - File upload: commit DB row before writing to disk to prevent orphaned files - GP% clamped to 100% in GPReport (credit notes can inflate above 100%) - Added CSV export to GPReport (suppliers, daily data, allowances breakdown) - Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility - DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py - N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N) - Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution - Budget spend rate input: rejects negative values - GPReport allowances toggle: persisted to localStorage across page loads - DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars - Fixed SyntaxWarning from \\d in invoices.py docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6f6e16c88f
commit
ba075276b1
57 changed files with 15427 additions and 15274 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 > 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 > 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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}>£{fmt(report.mapped_revenue_net)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Mapped Cost</div>
|
||||
<div style={styles.statValue}>£{fmt(report.mapped_total_cost)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Total Revenue</div>
|
||||
<div style={styles.statValue}>£{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: £{fmt(course.course_revenue)}</span>
|
||||
<span style={{ marginRight: '1.5rem' }}>Cost: £{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' }}>£{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}>
|
||||
£{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' }}>£{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}>£{fmt(report.mapped_revenue_net)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Mapped Cost</div>
|
||||
<div style={styles.statValue}>£{fmt(report.mapped_total_cost)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Total Revenue</div>
|
||||
<div style={styles.statValue}>£{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: £{fmt(course.course_revenue)}</span>
|
||||
<span style={{ marginRight: '1.5rem' }}>Cost: £{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' }}>£{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}>
|
||||
£{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' }}>£{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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue