Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

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