import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useParams, useNavigate } from 'react-router-dom' import { useAuth } from '../App' interface EventOrderDetail { id: number name: string event_date: string | null notes: string | null status: string items: EventOrderItemDetail[] created_at: string updated_at: string } interface EventOrderItemDetail { id: number recipe_id: number recipe_name: string recipe_type: string batch_portions: number quantity: number cost_per_portion: number | null subtotal: number | null notes: string | null sort_order: number } interface ShoppingListItem { ingredient_id: number ingredient_name: string category: string total_quantity: number adjusted_quantity: number unit: string yield_percent: number sources: Array<{ supplier_name: string product_code: string | null pack_description: string | null suggested_packs: number | null cost_per_pack: number | null subtotal: number | null }> recipe_breakdown: Array<{ recipe_name: string; quantity: number }> } interface BySupplierItem { ingredient_name: string quantity_needed: number unit: string supplier_name: string product_code: string | null pack_description: string | null suggested_packs: number | null cost_per_pack: number | null subtotal: number | null } interface ShoppingListResponse { items: ShoppingListItem[] by_supplier: Record } interface RecipeOption { id: number name: string recipe_type: string batch_portions: number cost_per_portion: number | null } interface MenuOption { id: number name: string description: string | null is_active: boolean item_count: number division_count: number } interface MenuDetail { id: number name: string divisions: Array<{ id: number name: string items: Array<{ id: number recipe_id: number | null display_name: string description: string | null is_archived: boolean }> }> } interface MenuDishEntry { recipe_id: number display_name: string division_name: string checked: boolean quantity: number } export default function EventOrderEditor() { const { id } = useParams<{ id: string }>() const orderId = parseInt(id || '0') const { token } = useAuth() const navigate = useNavigate() const queryClient = useQueryClient() // Modal state: which modal is open const [addModalType, setAddModalType] = useState<'recipe' | 'dish' | 'menu' | null>(null) const [searchText, setSearchText] = useState('') const [selectedRecipeId, setSelectedRecipeId] = useState(null) const [recipeQty, setRecipeQty] = useState('1') // Menu modal state const [selectedMenuId, setSelectedMenuId] = useState(null) const [menuDishes, setMenuDishes] = useState([]) const [covers, setCovers] = useState('1') const [showShopping, setShowShopping] = useState(false) const [groupBySupplier, setGroupBySupplier] = useState(false) const { data: order } = useQuery({ queryKey: ['event-order', orderId], queryFn: async () => { const res = await fetch(`/api/event-orders/${orderId}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Not found') return res.json() }, enabled: !!token && !!orderId, }) // Fetch recipes filtered by type for recipe/dish modals const recipeType = addModalType === 'recipe' ? 'component' : addModalType === 'dish' ? 'dish' : null const { data: recipes } = useQuery({ queryKey: ['recipes-for-event', recipeType], queryFn: async () => { const res = await fetch(`/api/recipes?recipe_type=${recipeType}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed') return res.json() }, enabled: !!token && (addModalType === 'recipe' || addModalType === 'dish'), }) // Fetch menus for menu modal const { data: menus } = useQuery({ queryKey: ['menus-for-event'], queryFn: async () => { const res = await fetch('/api/menus', { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed') return res.json() }, enabled: !!token && addModalType === 'menu' && !selectedMenuId, }) // Fetch selected menu detail const { data: menuDetail } = useQuery({ queryKey: ['menu-detail-for-event', selectedMenuId], queryFn: async () => { const res = await fetch(`/api/menus/${selectedMenuId}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed') return res.json() }, enabled: !!token && !!selectedMenuId, }) const { data: shoppingList } = useQuery({ queryKey: ['event-shopping-list', orderId, groupBySupplier], queryFn: async () => { const params = groupBySupplier ? '?group_by_supplier=true' : '' const res = await fetch(`/api/event-orders/${orderId}/shopping-list${params}`, { headers: { Authorization: `Bearer ${token}` }, }) return res.json() }, enabled: !!token && !!orderId && showShopping, }) const addItemMutation = useMutation({ mutationFn: async (data: { recipe_id: number; quantity: number }) => { const res = await fetch(`/api/event-orders/${orderId}/items`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!res.ok) throw new Error('Failed') }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['event-order', orderId] }) queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] }) closeModal() }, }) const bulkAddMutation = useMutation({ mutationFn: async (items: Array<{ recipe_id: number; quantity: number; notes?: string }>) => { const res = await fetch(`/api/event-orders/${orderId}/items/bulk`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }) if (!res.ok) throw new Error('Failed') return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['event-order', orderId] }) queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] }) closeModal() }, }) const removeItemMutation = useMutation({ mutationFn: async (itemId: number) => { const res = await fetch(`/api/event-orders/items/${itemId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed') }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['event-order', orderId] }) queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] }) }, }) const updateStatusMutation = useMutation({ mutationFn: async (status: string) => { const res = await fetch(`/api/event-orders/${orderId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), }) if (!res.ok) throw new Error('Failed') }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-order', orderId] }), }) function closeModal() { setAddModalType(null) setSearchText('') setSelectedRecipeId(null) setRecipeQty('1') setSelectedMenuId(null) setMenuDishes([]) setCovers('1') } // When menu detail loads, build the dishes list function handleMenuSelected(menuId: number) { setSelectedMenuId(menuId) setSearchText('') } // Populate menuDishes when menuDetail loads const populateMenuDishes = (detail: MenuDetail) => { const existingRecipeIds = new Set((order?.items || []).map(i => i.recipe_id)) const dishes: MenuDishEntry[] = [] for (const div of detail.divisions) { for (const item of div.items) { if (item.recipe_id && !item.is_archived) { dishes.push({ recipe_id: item.recipe_id, display_name: item.display_name, division_name: div.name, checked: !existingRecipeIds.has(item.recipe_id), quantity: parseInt(covers) || 1, }) } } } setMenuDishes(dishes) } // Effect: when menuDetail changes, populate if (menuDetail && menuDishes.length === 0 && selectedMenuId) { populateMenuDishes(menuDetail) } function handleCoversChange(val: string) { setCovers(val) const num = parseInt(val) || 1 setMenuDishes(prev => prev.map(d => ({ ...d, quantity: num }))) } function handleMenuDishQty(idx: number, val: string) { setMenuDishes(prev => prev.map((d, i) => i === idx ? { ...d, quantity: parseInt(val) || 0 } : d)) } function handleMenuDishToggle(idx: number) { setMenuDishes(prev => prev.map((d, i) => i === idx ? { ...d, checked: !d.checked } : d)) } function handleBulkAdd() { const items = menuDishes .filter(d => d.checked && d.quantity > 0) .map(d => ({ recipe_id: d.recipe_id, quantity: d.quantity, notes: `From menu: ${menuDetail?.name || ''}`, })) if (items.length > 0) bulkAddMutation.mutate(items) } if (!order) return
Loading event order...
const totalCost = order.items.reduce((sum, i) => sum + (i.subtotal || 0), 0) const totalServings = order.items.reduce((sum, i) => { if (i.recipe_type === 'dish') return sum + i.quantity return sum + (i.quantity * i.batch_portions) }, 0) // Filter recipes by search const filteredRecipes = (recipes || []).filter(r => !searchText || r.name.toLowerCase().includes(searchText.toLowerCase()) ) // Filter menus by search const activeMenus = (menus || []).filter(m => m.is_active) const filteredMenus = activeMenus.filter(m => !searchText || m.name.toLowerCase().includes(searchText.toLowerCase()) ) // Supplier grouped data const bySupplier = shoppingList?.by_supplier || {} const supplierNames = Object.keys(bySupplier).sort() return (
{order.status === 'DRAFT' && ( )} {order.status === 'FINALISED' && ( )}

{order.name}

{order.event_date &&
{order.event_date}
} {order.notes &&
{order.notes}
}
{order.status}
{/* Items */}

Items

{order.items.length > 0 ? ( {order.items.map(item => ( ))}
Recipe Type Quantity Cost/Portion Subtotal
navigate(`/recipes/${item.recipe_id}`)}> {item.recipe_name} {item.notes && ( {item.notes} )} {item.recipe_type === 'component' ? `Component (${item.batch_portions} per batch)` : 'Dish'} {item.quantity} {item.cost_per_portion != null ? `£${item.cost_per_portion.toFixed(2)}` : '-'} {item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}
Total: £{totalCost.toFixed(2)}
) : (
No items added yet
)} {order.items.length > 0 && (
~{totalServings} total servings | £{totalServings > 0 ? (totalCost / totalServings).toFixed(2) : '0.00'}/head
)}
{/* Shopping list */} {order.items.length > 0 && (

Shopping List

{showShopping && shoppingList && !groupBySupplier && ( {shoppingList.items.map(item => ( ))}
Ingredient Category Quantity Source Packs Est. Cost
{item.ingredient_name} {item.category} {item.adjusted_quantity}{item.unit} {item.sources.length > 0 ? item.sources[0].supplier_name : No source } {item.sources.length > 0 && item.sources[0].suggested_packs ? `${item.sources[0].suggested_packs} × ${item.sources[0].pack_description || 'pack'}` : '-' } {item.sources.length > 0 && item.sources[0].subtotal != null ? `£${item.sources[0].subtotal.toFixed(2)}` : '-' }
)} {showShopping && shoppingList && groupBySupplier && (
{supplierNames.length === 0 && (

No supplier data available

)} {supplierNames.map(supplier => { const items = bySupplier[supplier] const supplierTotal = items.reduce((sum, i) => sum + (i.subtotal || 0), 0) return (
{supplier} {supplierTotal > 0 && ( £{supplierTotal.toFixed(2)} )}
{items.map((item, idx) => ( ))}
Ingredient Quantity Packs Est. Cost
{item.ingredient_name} {item.quantity_needed}{item.unit} {item.suggested_packs ? `${item.suggested_packs} × ${item.pack_description || 'pack'}` : '-' } {item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}
) })} {/* Show unsourced ingredients */} {shoppingList.items.filter(i => i.sources.length === 0).length > 0 && (
No Supplier
{shoppingList.items.filter(i => i.sources.length === 0).map(item => ( ))}
Ingredient Category Quantity
{item.ingredient_name} {item.category} {item.adjusted_quantity}{item.unit}
)}
)}
)} {/* Add Recipe / Add Dish modal */} {(addModalType === 'recipe' || addModalType === 'dish') && (
e.stopPropagation()}>

Add {addModalType === 'recipe' ? 'Recipe' : 'Dish'}

setSearchText(e.target.value)} placeholder={`Search ${addModalType === 'recipe' ? 'recipes' : 'dishes'}...`} style={styles.input} autoFocus />
{filteredRecipes.map(r => (
setSelectedRecipeId(r.id)} style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', background: selectedRecipeId === r.id ? '#eff6ff' : '#fff', borderBottom: '1px solid #f3f4f6', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }} > {r.name} {addModalType === 'recipe' ? `batch: ${r.batch_portions}` : r.cost_per_portion != null ? `£${r.cost_per_portion.toFixed(2)}/portion` : '' }
))} {filteredRecipes.length === 0 && (

No {addModalType === 'recipe' ? 'recipes' : 'dishes'} found

)}
{selectedRecipeId && ( <> setRecipeQty(e.target.value)} style={styles.input} min="1" /> )}
)} {/* Add Menu modal */} {addModalType === 'menu' && (
e.stopPropagation()}>

{selectedMenuId ? `Add from: ${menuDetail?.name || 'Loading...'}` : 'Add Menu'}

{!selectedMenuId && ( <> setSearchText(e.target.value)} placeholder="Search menus..." style={styles.input} autoFocus />
{filteredMenus.map(m => (
handleMenuSelected(m.id)} style={{ padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }} onMouseEnter={e => (e.currentTarget.style.background = '#f9fafb')} onMouseLeave={e => (e.currentTarget.style.background = '#fff')} >
{m.name} {m.description &&
{m.description}
}
{m.item_count} dish{m.item_count !== 1 ? 'es' : ''}
))} {filteredMenus.length === 0 && (

No active menus found

)}
)} {selectedMenuId && menuDishes.length > 0 && ( <>
handleCoversChange(e.target.value)} style={{ ...styles.input, width: '80px' }} min="1" /> Sets quantity for all dishes
{menuDishes.map((dish, idx) => { const alreadyOnOrder = (order?.items || []).some(i => i.recipe_id === dish.recipe_id) return ( ) })}
Dish Section Qty
handleMenuDishToggle(idx)} disabled={alreadyOnOrder} /> {dish.display_name} {alreadyOnOrder && already added} {dish.division_name} handleMenuDishQty(idx, e.target.value)} style={{ width: '60px', padding: '0.25rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.85rem', textAlign: 'center' }} min="0" disabled={!dish.checked || alreadyOnOrder} />
)} {selectedMenuId && menuDishes.length === 0 && menuDetail && (

No dishes on this menu

)}
{selectedMenuId && ( )} {selectedMenuId && ( )}
)}
) } const styles: Record = { page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' }, topBar: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }, backBtn: { padding: '0.5rem 1rem', background: 'white', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer' }, headerCard: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', background: 'white', padding: '1.25rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' }, section: { background: 'white', padding: '1rem 1.25rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' }, sectionHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }, table: { width: '100%', borderCollapse: 'collapse' as const }, th: { padding: '0.5rem 0.5rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666' }, td: { padding: '0.4rem 0.5rem', fontSize: '0.85rem', borderBottom: '1px solid #f0f0f0' }, addBtn: { padding: '0.35rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' }, removeBtn: { padding: '2px 6px', background: 'none', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.7rem', color: '#e94560' }, primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 }, secondaryBtn: { padding: '0.5rem 1rem', background: 'white', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer' }, cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' }, loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' }, 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 }, }