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('') const [showUnassessedOnly, setShowUnassessedOnly] = useState(false) const [expandedId, setExpandedId] = useState(null) // Fetch all non-archived ingredients const { data: ingredients } = useQuery({ 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({ 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({ 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>({ 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>({ 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 (

Bulk Allergen Assessment

{/* Filters */}
setSearch(e.target.value)} style={styles.searchInput} /> {filtered.length} ingredients
{flagColumns.length === 0 ? (
No required flag categories found. Go to Settings > Food Flags and mark allergen categories as "Required".
) : (
{requiredCategories.map(cat => ( ))} {flagColumns.map(col => ( ))} {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 ( {/* None columns per required category */} {requiredCategories.map(cat => { const isNone = nones.includes(cat.id) return ( ) })} {/* 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 ( ) })} {/* Expanded detail row */} {isExpanded && ( )} ) })}
Ingredient None {col.flagCode || col.flagName}
toggleExpanded(ing)} title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'} > {isExpanded ? '\u25BC' : '\u25B6'} {ing.name} {ing.category_name && ( {ing.category_name} )} {ing.is_prepackaged && ( PKG )} toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })} disabled={toggleNoneMutation.isPending} style={{ cursor: 'pointer' }} title={`None apply for ${cat.name}`} /> 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)' : '')} />
{/* Notes */}
Notes
{ing.notes || 'No notes'}
{/* Product ingredients */}
Label Ingredients {ing.is_prepackaged && (prepackaged)}
{ing.product_ingredients || 'Not available'}
{/* Allergen suggestions */}
Keyword Suggestions
{!pendingSuggestions?.length ? (
No suggestions
) : (
{pendingSuggestions.map(s => (
{s.flag_name} {s.matched_keywords.join(', ')}
))}
)}
)}
) } const styles: Record = { 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' }, }