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:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
811
frontend/src/components/EventOrderEditor.tsx
Normal file
811
frontend/src/components/EventOrderEditor.tsx
Normal 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 },
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue