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,811 @@
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<string, BySupplierItem[]>
}
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<number | null>(null)
const [recipeQty, setRecipeQty] = useState('1')
// Menu modal state
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(null)
const [menuDishes, setMenuDishes] = useState<MenuDishEntry[]>([])
const [covers, setCovers] = useState('1')
const [showShopping, setShowShopping] = useState(false)
const [groupBySupplier, setGroupBySupplier] = useState(false)
const { data: order } = useQuery<EventOrderDetail>({
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<RecipeOption[]>({
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<MenuOption[]>({
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<MenuDetail>({
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<ShoppingListResponse>({
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 <div style={styles.loading}>Loading event order...</div>
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 (
<div style={styles.page}>
<div style={styles.topBar}>
<button onClick={() => navigate('/event-orders')} style={styles.backBtn}> Back</button>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{order.status === 'DRAFT' && (
<button onClick={() => updateStatusMutation.mutate('FINALISED')} style={styles.secondaryBtn}>Finalise</button>
)}
{order.status === 'FINALISED' && (
<button onClick={() => updateStatusMutation.mutate('ORDERED')} style={styles.primaryBtn}>Mark Ordered</button>
)}
</div>
</div>
<div style={styles.headerCard}>
<div>
<h2 style={{ margin: 0 }}>{order.name}</h2>
{order.event_date && <div style={{ color: '#888', marginTop: '4px' }}>{order.event_date}</div>}
{order.notes && <div style={{ color: '#666', marginTop: '4px', fontSize: '0.85rem' }}>{order.notes}</div>}
</div>
<span style={{
background: order.status === 'DRAFT' ? '#f59e0b' : order.status === 'FINALISED' ? '#3b82f6' : '#22c55e',
color: 'white', padding: '4px 12px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600,
}}>
{order.status}
</span>
</div>
{/* Items */}
<div style={styles.section}>
<div style={styles.sectionHeader}>
<h3 style={{ margin: 0 }}>Items</h3>
<div style={{ display: 'flex', gap: '0.35rem' }}>
<button onClick={() => setAddModalType('recipe')} style={styles.addBtn}>+ Recipe</button>
<button onClick={() => setAddModalType('dish')} style={styles.addBtn}>+ Dish</button>
<button onClick={() => setAddModalType('menu')} style={{ ...styles.addBtn, background: '#eff6ff', borderColor: '#93c5fd' }}>+ Menu</button>
</div>
</div>
{order.items.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Recipe</th>
<th style={styles.th}>Type</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Cost/Portion</th>
<th style={styles.th}>Subtotal</th>
<th style={styles.th}></th>
</tr>
</thead>
<tbody>
{order.items.map(item => (
<tr key={item.id}>
<td style={styles.td}>
<span style={{ cursor: 'pointer', color: '#3b82f6' }} onClick={() => navigate(`/recipes/${item.recipe_id}`)}>
{item.recipe_name}
</span>
{item.notes && (
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '0.5rem' }}>{item.notes}</span>
)}
</td>
<td style={styles.td}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{item.recipe_type === 'component' ? `Component (${item.batch_portions} per batch)` : 'Dish'}
</span>
</td>
<td style={styles.td}>{item.quantity}</td>
<td style={styles.td}>{item.cost_per_portion != null ? `£${item.cost_per_portion.toFixed(2)}` : '-'}</td>
<td style={styles.td}>{item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}</td>
<td style={styles.td}>
<button onClick={() => removeItemMutation.mutate(item.id)} style={styles.removeBtn}></button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #ddd' }}>
<td colSpan={4} style={{ ...styles.td, fontWeight: 600, textAlign: 'right' }}>Total:</td>
<td style={{ ...styles.td, fontWeight: 600 }}>£{totalCost.toFixed(2)}</td>
<td></td>
</tr>
</tfoot>
</table>
) : (
<div style={{ color: '#888', fontStyle: 'italic', padding: '1rem 0' }}>No items added yet</div>
)}
{order.items.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#666', marginTop: '0.5rem' }}>
~{totalServings} total servings | £{totalServings > 0 ? (totalCost / totalServings).toFixed(2) : '0.00'}/head
</div>
)}
</div>
{/* Shopping list */}
{order.items.length > 0 && (
<div style={styles.section}>
<div style={styles.sectionHeader}>
<h3 style={{ margin: 0 }}>Shopping List</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<label style={{ fontSize: '0.8rem' }}>
<input type="checkbox" checked={groupBySupplier} onChange={(e) => setGroupBySupplier(e.target.checked)} />
{' '}Group by supplier
</label>
<button onClick={() => setShowShopping(!showShopping)} style={styles.addBtn}>
{showShopping ? 'Hide' : 'Show'} Shopping List
</button>
</div>
</div>
{showShopping && shoppingList && !groupBySupplier && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Source</th>
<th style={styles.th}>Packs</th>
<th style={styles.th}>Est. Cost</th>
</tr>
</thead>
<tbody>
{shoppingList.items.map(item => (
<tr key={item.ingredient_id}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}><span style={{ fontSize: '0.75rem', color: '#888' }}>{item.category}</span></td>
<td style={styles.td}>{item.adjusted_quantity}{item.unit}</td>
<td style={styles.td}>
{item.sources.length > 0
? item.sources[0].supplier_name
: <span style={{ color: '#f59e0b', fontSize: '0.8rem' }}>No source</span>
}
</td>
<td style={styles.td}>
{item.sources.length > 0 && item.sources[0].suggested_packs
? `${item.sources[0].suggested_packs} × ${item.sources[0].pack_description || 'pack'}`
: '-'
}
</td>
<td style={styles.td}>
{item.sources.length > 0 && item.sources[0].subtotal != null
? `£${item.sources[0].subtotal.toFixed(2)}`
: '-'
}
</td>
</tr>
))}
</tbody>
</table>
)}
{showShopping && shoppingList && groupBySupplier && (
<div>
{supplierNames.length === 0 && (
<p style={{ color: '#888', fontStyle: 'italic', fontSize: '0.85rem' }}>No supplier data available</p>
)}
{supplierNames.map(supplier => {
const items = bySupplier[supplier]
const supplierTotal = items.reduce((sum, i) => sum + (i.subtotal || 0), 0)
return (
<div key={supplier} style={{ marginBottom: '1rem' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.4rem 0.5rem', background: '#f8f9fa', borderRadius: '4px', marginBottom: '0.25rem',
}}>
<strong style={{ fontSize: '0.85rem' }}>{supplier}</strong>
{supplierTotal > 0 && (
<span style={{ fontSize: '0.8rem', color: '#666' }}>£{supplierTotal.toFixed(2)}</span>
)}
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Packs</th>
<th style={styles.th}>Est. Cost</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}>{item.quantity_needed}{item.unit}</td>
<td style={styles.td}>
{item.suggested_packs
? `${item.suggested_packs} × ${item.pack_description || 'pack'}`
: '-'
}
</td>
<td style={styles.td}>
{item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
{/* Show unsourced ingredients */}
{shoppingList.items.filter(i => i.sources.length === 0).length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<div style={{
padding: '0.4rem 0.5rem', background: '#fef3c7', borderRadius: '4px', marginBottom: '0.25rem',
}}>
<strong style={{ fontSize: '0.85rem', color: '#92400e' }}>No Supplier</strong>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Quantity</th>
</tr>
</thead>
<tbody>
{shoppingList.items.filter(i => i.sources.length === 0).map(item => (
<tr key={item.ingredient_id}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}><span style={{ fontSize: '0.75rem', color: '#888' }}>{item.category}</span></td>
<td style={styles.td}>{item.adjusted_quantity}{item.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
{/* Add Recipe / Add Dish modal */}
{(addModalType === 'recipe' || addModalType === 'dish') && (
<div style={styles.overlay} onClick={closeModal}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>Add {addModalType === 'recipe' ? 'Recipe' : 'Dish'}</h3>
<button onClick={closeModal} style={styles.closeBtn}></button>
</div>
<div style={styles.modalBody}>
<input
type="text"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={`Search ${addModalType === 'recipe' ? 'recipes' : 'dishes'}...`}
style={styles.input}
autoFocus
/>
<div style={{ maxHeight: '250px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px', marginTop: '0.5rem' }}>
{filteredRecipes.map(r => (
<div
key={r.id}
onClick={() => 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',
}}
>
<span style={{ fontWeight: selectedRecipeId === r.id ? 600 : 400, fontSize: '0.9rem' }}>
{r.name}
</span>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{addModalType === 'recipe'
? `batch: ${r.batch_portions}`
: r.cost_per_portion != null ? `£${r.cost_per_portion.toFixed(2)}/portion` : ''
}
</span>
</div>
))}
{filteredRecipes.length === 0 && (
<p style={{ padding: '0.75rem', color: '#999', textAlign: 'center', margin: 0, fontSize: '0.85rem' }}>
No {addModalType === 'recipe' ? 'recipes' : 'dishes'} found
</p>
)}
</div>
{selectedRecipeId && (
<>
<label style={styles.label}>
Quantity ({addModalType === 'recipe' ? 'batches' : 'servings'})
</label>
<input type="number" value={recipeQty} onChange={e => setRecipeQty(e.target.value)} style={styles.input} min="1" />
</>
)}
</div>
<div style={styles.modalFooter}>
<button onClick={closeModal} style={styles.cancelBtn}>Cancel</button>
<button
onClick={() => selectedRecipeId && addItemMutation.mutate({ recipe_id: selectedRecipeId, quantity: parseInt(recipeQty) || 1 })}
disabled={!selectedRecipeId || !recipeQty || addItemMutation.isPending}
style={styles.primaryBtn}
>
{addItemMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
</div>
)}
{/* Add Menu modal */}
{addModalType === 'menu' && (
<div style={styles.overlay} onClick={closeModal}>
<div style={{ ...styles.modal, width: '600px' }} onClick={e => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>
{selectedMenuId ? `Add from: ${menuDetail?.name || 'Loading...'}` : 'Add Menu'}
</h3>
<button onClick={closeModal} style={styles.closeBtn}></button>
</div>
<div style={styles.modalBody}>
{!selectedMenuId && (
<>
<input
type="text"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="Search menus..."
style={styles.input}
autoFocus
/>
<div style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px', marginTop: '0.5rem' }}>
{filteredMenus.map(m => (
<div
key={m.id}
onClick={() => 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')}
>
<div>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>{m.name}</span>
{m.description && <div style={{ fontSize: '0.75rem', color: '#888' }}>{m.description}</div>}
</div>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{m.item_count} dish{m.item_count !== 1 ? 'es' : ''}
</span>
</div>
))}
{filteredMenus.length === 0 && (
<p style={{ padding: '0.75rem', color: '#999', textAlign: 'center', margin: 0, fontSize: '0.85rem' }}>
No active menus found
</p>
)}
</div>
</>
)}
{selectedMenuId && menuDishes.length > 0 && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: '#555', whiteSpace: 'nowrap' }}>Covers:</label>
<input
type="number"
value={covers}
onChange={e => handleCoversChange(e.target.value)}
style={{ ...styles.input, width: '80px' }}
min="1"
/>
<span style={{ fontSize: '0.75rem', color: '#888' }}>Sets quantity for all dishes</span>
</div>
<div style={{ maxHeight: '350px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px' }}>
<table style={{ ...styles.table, fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ ...styles.th, width: '30px' }}></th>
<th style={styles.th}>Dish</th>
<th style={styles.th}>Section</th>
<th style={{ ...styles.th, width: '80px' }}>Qty</th>
</tr>
</thead>
<tbody>
{menuDishes.map((dish, idx) => {
const alreadyOnOrder = (order?.items || []).some(i => i.recipe_id === dish.recipe_id)
return (
<tr key={idx} style={{ opacity: alreadyOnOrder ? 0.5 : 1 }}>
<td style={styles.td}>
<input
type="checkbox"
checked={dish.checked}
onChange={() => handleMenuDishToggle(idx)}
disabled={alreadyOnOrder}
/>
</td>
<td style={styles.td}>
{dish.display_name}
{alreadyOnOrder && <span style={{ fontSize: '0.7rem', color: '#f59e0b', marginLeft: '0.5rem' }}>already added</span>}
</td>
<td style={styles.td}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>{dish.division_name}</span>
</td>
<td style={styles.td}>
<input
type="number"
value={dish.quantity}
onChange={e => 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}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
{selectedMenuId && menuDishes.length === 0 && menuDetail && (
<p style={{ color: '#888', fontStyle: 'italic', fontSize: '0.85rem' }}>No dishes on this menu</p>
)}
</div>
<div style={styles.modalFooter}>
{selectedMenuId && (
<button onClick={() => { setSelectedMenuId(null); setMenuDishes([]); setSearchText('') }} style={styles.cancelBtn}>
Back
</button>
)}
<button onClick={closeModal} style={styles.cancelBtn}>Cancel</button>
{selectedMenuId && (
<button
onClick={handleBulkAdd}
disabled={menuDishes.filter(d => d.checked && d.quantity > 0).length === 0 || bulkAddMutation.isPending}
style={styles.primaryBtn}
>
{bulkAddMutation.isPending ? 'Adding...' : `Add ${menuDishes.filter(d => d.checked && d.quantity > 0).length} Dishes`}
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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 },
}