kitchen/frontend/src/components/SearchLineItems.tsx
jtricerolph 0d6ca9b979 Fix internal navigation links: add /kitchen/ prefix to all bare hrefs
Bare <a href="/invoice/..."> tags bypass React Router basename, causing
hard navigations to absolute paths with no NPM route. Added /kitchen/
prefix to all internal href values across 12 components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:11:41 +00:00

1139 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Line Items Search Page
* Search consolidated line items with price change detection.
*/
import { useState, useMemo, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import IngredientModal from './IngredientModal'
import { IngredientModalResult, LineItemResult } from '../utils/ingredientHelpers'
import {
useDebounce,
usePersistedState,
getDefaultDateRange,
formatDateForDisplay,
formatCurrency,
formatQuantity,
getPriceStatusConfig,
SEARCH_STORAGE_KEYS,
} from '../utils/searchHelpers'
import LineItemHistoryModal from './LineItemHistoryModal'
interface LineItemSearchItem {
product_code: string | null
description: string | null
supplier_id: number | null
supplier_name: string | null
unit: string | null
most_recent_price: number | null
earliest_price_in_period: number | null
price_change_percent: number | null
price_change_status: string
total_quantity: number | null
occurrence_count: number
most_recent_invoice_id: number
most_recent_invoice_number: string | null
most_recent_date: string | null
has_definition: boolean
portions_per_unit: number | null
pack_quantity: number | null
most_recent_line_item_id: number | null
most_recent_line_number: number | null
most_recent_raw_content: string | null
most_recent_pack_quantity: number | null
most_recent_unit_size: number | null
most_recent_unit_size_type: string | null
// Ingredient mapping
ingredient_id: number | null
ingredient_name: string | null
ingredient_standard_unit: string | null
price_per_std_unit: number | null
}
interface GroupSummary {
name: string
count: number
total: number | null
}
interface SearchResponse {
items: LineItemSearchItem[]
total_count: number
grouped_by: string | null
groups: GroupSummary[] | null
}
interface Supplier {
id: number
name: string
}
interface SearchSettings {
price_change_lookback_days: number
price_change_amber_threshold: number
price_change_red_threshold: number
}
interface IngredientSuggestion {
id: number
name: string
category_name: string | null
standard_unit: string
yield_percent: number
similarity?: number
}
const keys = SEARCH_STORAGE_KEYS.lineItems
export default function SearchLineItems() {
const { token } = useAuth()
const queryClient = useQueryClient()
const defaultDates = useMemo(() => getDefaultDateRange(), [])
// Persisted search state
const [searchInput, setSearchInput] = usePersistedState(keys.query, '')
const [supplierId, setSupplierId] = usePersistedState<string>(keys.supplier, '')
const [dateFrom, setDateFrom] = usePersistedState(keys.dateFrom, defaultDates.from)
const [dateTo, setDateTo] = usePersistedState(keys.dateTo, defaultDates.to)
const [groupBy, setGroupBy] = usePersistedState<string>(keys.groupBy, '')
const [priceChangeFilter, setPriceChangeFilter] = usePersistedState<string>(keys.priceChangeFilter, '')
const [mappedFilter, setMappedFilter] = usePersistedState<string>(keys.mappedFilter, '')
const [sortColumn, setSortColumn] = usePersistedState<string>(keys.sortColumn, '')
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>(keys.sortDirection, 'asc')
// History modal state
const [historyModal, setHistoryModal] = useState<{
isOpen: boolean
productCode: string | null
description: string | null
unit: string | null
supplierId: number
supplierName: string
} | null>(null)
// Ingredient mapping modal state (same as Review.tsx)
const [modalItem, setModalItem] = useState<LineItemSearchItem | null>(null)
const [costEdits, setCostEdits] = useState<{ pack_quantity?: number | null; unit_size?: number | null; unit_size_type?: string | null; unit_price?: number | null }>({})
const [ingredientSearch, setIngredientSearch] = useState('')
const [ingredientSuggestions, setIngredientSuggestions] = useState<IngredientSuggestion[]>([])
const [ingredientSearchLoading, setIngredientSearchLoading] = useState(false)
const [selectedIngredientId, setSelectedIngredientId] = useState<number | null>(null)
const [selectedIngredientName, setSelectedIngredientName] = useState('')
const [selectedIngredientUnit, setSelectedIngredientUnit] = useState('')
const [showCreateIngredient, setShowCreateIngredient] = useState(false)
const [conversionDisplay, setConversionDisplay] = useState('')
const [savingMapping, setSavingMapping] = useState(false)
// Description alias suggestion state
const [aliasSuggestions, setAliasSuggestions] = useState<Record<string, {
description: string
source_id: number
ingredient_id: number
ingredient_name: string | null
canonical_description: string
product_code: string | null
similarity: number
price_difference: number | null
}>>({})
const [addingAliasFor, setAddingAliasFor] = useState<string | null>(null)
// Debounce search input
const debouncedSearch = useDebounce(searchInput, 300)
// Fetch suppliers
const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/kitchen/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
})
// Fetch search settings (for price change thresholds)
const { data: _searchSettings } = useQuery<SearchSettings>({
queryKey: ['search-settings'],
queryFn: async () => {
const res = await fetch('/kitchen/api/search/settings', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch search settings')
return res.json()
},
staleTime: 60000, // Cache for 1 minute
})
// Search line items
const { data, isLoading, error } = useQuery<SearchResponse>({
queryKey: ['search-line-items', debouncedSearch, supplierId, dateFrom, dateTo, groupBy, mappedFilter],
queryFn: async () => {
const params = new URLSearchParams()
if (debouncedSearch) params.set('q', debouncedSearch)
if (supplierId) params.set('supplier_id', supplierId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (groupBy) params.set('group_by', groupBy)
if (mappedFilter) params.set('mapped', mappedFilter)
params.set('limit', '200')
const res = await fetch(`/kitchen/api/search/line-items?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Search failed')
return res.json()
},
enabled: !!token,
})
// Fetch alias suggestions for unmapped items without product codes
useEffect(() => {
if (!data?.items || !token) return
// Group unmapped items by supplier for batch requests
const bySupplier = new Map<number, { description: string; price?: number }[]>()
for (const item of data.items) {
if (!item.ingredient_id && !item.product_code && item.description && item.supplier_id) {
if (!bySupplier.has(item.supplier_id)) bySupplier.set(item.supplier_id, [])
bySupplier.get(item.supplier_id)!.push({
description: item.description,
price: item.most_recent_price ?? undefined,
})
}
}
if (bySupplier.size === 0) { setAliasSuggestions({}); return }
const allSuggestions: typeof aliasSuggestions = {}
const promises = Array.from(bySupplier.entries()).map(([sid, items]) =>
fetch('/kitchen/api/ingredients/sources/alias-suggestions', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: sid, items }),
})
.then(res => res.ok ? res.json() : [])
.then((suggestions: any[]) => {
for (const s of suggestions) {
allSuggestions[`${sid}:${s.description.toLowerCase()}`] = s
}
})
.catch(() => {})
)
Promise.all(promises).then(() => setAliasSuggestions({ ...allSuggestions }))
}, [data?.items, token])
const addDescriptionAliasMutation = useMutation({
mutationFn: async ({ sourceId, alias }: { sourceId: number; alias: string }) => {
const res = await fetch(`/kitchen/api/ingredients/sources/${sourceId}/aliases`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ alias }),
})
if (!res.ok) throw new Error('Failed to add alias')
return res.json()
},
onSuccess: (_data, variables) => {
setAliasSuggestions(prev => {
const next = { ...prev }
// Remove matching key
for (const key of Object.keys(next)) {
if (key.endsWith(`:${variables.alias.toLowerCase()}`)) delete next[key]
}
return next
})
setAddingAliasFor(null)
queryClient.invalidateQueries({ queryKey: ['search-line-items'] })
},
})
const openHistoryModal = (item: LineItemSearchItem) => {
if (!item.supplier_id) return
setHistoryModal({
isOpen: true,
productCode: item.product_code,
description: item.description,
unit: item.unit,
supplierId: item.supplier_id,
supplierName: item.supplier_name || 'Unknown',
})
}
const handleScaleClick = async (item: LineItemSearchItem) => {
setModalItem(item)
setCostEdits({
pack_quantity: item.most_recent_pack_quantity ? Number(item.most_recent_pack_quantity) : null,
unit_size: item.most_recent_unit_size ? Number(item.most_recent_unit_size) : null,
unit_size_type: item.most_recent_unit_size_type,
unit_price: item.most_recent_price != null ? Number(item.most_recent_price) : null,
})
setSelectedIngredientId(item.ingredient_id || null)
setSelectedIngredientName(item.ingredient_name || '')
setSelectedIngredientUnit(item.ingredient_standard_unit || '')
setShowCreateIngredient(false)
setIngredientSearch('')
setIngredientSuggestions([])
setConversionDisplay('')
setSavingMapping(false)
// Show conversion display if pack data exists
if (item.most_recent_unit_size && item.most_recent_unit_size_type) {
updateConversionDisplay(item.ingredient_standard_unit || undefined, {
pack_quantity: item.most_recent_pack_quantity,
unit_size: item.most_recent_unit_size,
unit_size_type: item.most_recent_unit_size_type,
unit_price: item.most_recent_price,
})
}
// Auto-suggest ingredient match from description if unmapped
if (item.description && !item.ingredient_id) {
searchIngredients(item.description)
}
}
const searchIngredients = async (query: string) => {
if (!query || query.length < 2) {
setIngredientSuggestions([])
return
}
setIngredientSearchLoading(true)
try {
const res = await fetch(`/kitchen/api/ingredients/suggest?description=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) setIngredientSuggestions(await res.json())
} catch { /* ignore */ }
setIngredientSearchLoading(false)
}
const selectIngredient = (ing: IngredientSuggestion) => {
setSelectedIngredientId(ing.id)
setSelectedIngredientName(ing.name)
setSelectedIngredientUnit(ing.standard_unit)
setIngredientSearch('')
setIngredientSuggestions([])
if (!costEdits.unit_size_type) {
setCostEdits(prev => ({ ...prev, unit_size_type: ing.standard_unit }))
}
updateConversionDisplay(ing.standard_unit)
}
const handleIngredientCreated = (_result: IngredientModalResult) => {
// IngredientModal already created the source mapping, so close everything
queryClient.invalidateQueries({ queryKey: ['search-line-items'] })
closeModal()
}
const updateConversionDisplay = (stdUnit?: string, overrides?: Record<string, unknown>) => {
const edits = overrides ? { ...costEdits, ...overrides } : costEdits
const unit = stdUnit || selectedIngredientUnit || (edits.unit_size_type as string)
const pq = (edits.pack_quantity as number) || 1
const us = edits.unit_size as number
const ust = edits.unit_size_type as string
const up = edits.unit_price as number
if (!us || !ust || !unit) { setConversionDisplay(''); return }
const conversions: Record<string, Record<string, number>> = {
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
each: { each: 1 },
}
const conv = conversions[ust]?.[unit]
if (!conv) { setConversionDisplay(ust !== unit ? `Cannot convert ${ust}${unit}` : `${us}${ust}`); return }
const totalStd = pq * us * conv
const pricePerStd = up ? (up / totalStd) : null
const packNote = pq > 1 ? `${pq} × ${us}${ust} = ` : ''
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${unit}`
if (pricePerStd) {
display += ` → £${pricePerStd.toFixed(4)} per ${unit}`
if (unit === 'g') display += `${(pricePerStd * 1000).toFixed(2)}/kg)`
else if (unit === 'ml') display += `${(pricePerStd * 1000).toFixed(2)}/ltr)`
}
setConversionDisplay(display)
}
const closeModal = () => {
setModalItem(null)
setCostEdits({})
setSelectedIngredientId(null)
setSelectedIngredientName('')
setSelectedIngredientUnit('')
setShowCreateIngredient(false)
setIngredientSearch('')
setIngredientSuggestions([])
setConversionDisplay('')
}
const saveMapping = async () => {
if (!modalItem) return
setSavingMapping(true)
try {
// Update the most recent line item's pack fields if we have one
if (modalItem.most_recent_line_item_id && modalItem.most_recent_invoice_id) {
await fetch(`/kitchen/api/invoices/${modalItem.most_recent_invoice_id}/line-items/${modalItem.most_recent_line_item_id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
pack_quantity: costEdits.pack_quantity || null,
unit_size: costEdits.unit_size || null,
unit_size_type: costEdits.unit_size_type || null,
...(selectedIngredientId ? { ingredient_id: selectedIngredientId } : {}),
}),
})
}
// Create ingredient source mapping
if (selectedIngredientId && modalItem.supplier_id) {
const sourceData: Record<string, unknown> = {
supplier_id: modalItem.supplier_id,
pack_quantity: costEdits.pack_quantity || 1,
unit_size: costEdits.unit_size || null,
unit_size_type: costEdits.unit_size_type || selectedIngredientUnit || null,
}
if (modalItem.product_code) {
sourceData.product_code = modalItem.product_code
} else if (modalItem.description) {
sourceData.description_pattern = modalItem.description.substring(0, 100).toLowerCase().trim()
}
if (costEdits.unit_price) sourceData.latest_unit_price = costEdits.unit_price
if (modalItem.most_recent_invoice_id) sourceData.invoice_id = modalItem.most_recent_invoice_id
await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData),
})
}
queryClient.invalidateQueries({ queryKey: ['search-line-items'] })
closeModal()
} catch (err) {
console.error('Failed to save mapping:', err)
}
setSavingMapping(false)
}
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? ' ▲' : ' ▼'
}
// Filter items by price change status
const filteredItems = useMemo(() => {
if (!data?.items) return []
if (!priceChangeFilter) return data.items
return data.items.filter((item) => {
if (priceChangeFilter === 'no_history') {
return item.price_change_status === 'no_history'
} else if (priceChangeFilter === 'no_change') {
return item.price_change_status === 'consistent' || item.price_change_status === 'acknowledged'
} else if (priceChangeFilter === 'increase') {
return item.price_change_percent !== null && item.price_change_percent > 0
} else if (priceChangeFilter === 'decrease') {
return item.price_change_percent !== null && item.price_change_percent < 0
}
return true
})
}, [data?.items, priceChangeFilter])
// Sort filtered items
const sortedItems = useMemo(() => {
if (!filteredItems.length || !sortColumn) return filteredItems
const sorted = [...filteredItems].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'code':
aVal = a.product_code || ''
bVal = b.product_code || ''
break
case 'description':
aVal = a.description || ''
bVal = b.description || ''
break
case 'supplier':
aVal = a.supplier_name || ''
bVal = b.supplier_name || ''
break
case 'price':
aVal = a.most_recent_price ?? -Infinity
bVal = b.most_recent_price ?? -Infinity
break
case 'ingredient_rate':
aVal = a.price_per_std_unit != null
? (typeof a.price_per_std_unit === 'string' ? parseFloat(a.price_per_std_unit) : a.price_per_std_unit)
: -Infinity
bVal = b.price_per_std_unit != null
? (typeof b.price_per_std_unit === 'string' ? parseFloat(b.price_per_std_unit) : b.price_per_std_unit)
: -Infinity
break
case 'total_quantity':
aVal = a.total_quantity ?? -Infinity
bVal = b.total_quantity ?? -Infinity
break
case 'occurrence_count':
aVal = a.occurrence_count ?? 0
bVal = b.occurrence_count ?? 0
break
case 'last_invoice':
aVal = a.most_recent_invoice_number || ''
bVal = b.most_recent_invoice_number || ''
break
case 'last_date':
aVal = a.most_recent_date || ''
bVal = b.most_recent_date || ''
break
default:
return 0
}
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
} else {
return sortDirection === 'asc'
? aVal - bVal
: bVal - aVal
}
})
return sorted
}, [filteredItems, sortColumn, sortDirection])
const renderPriceStatus = (item: LineItemSearchItem) => {
const config = getPriceStatusConfig(item.price_change_status)
if (!config.icon) return null
// Show icon for consistent prices (green tick)
if (item.price_change_status === 'consistent' || item.price_change_status === 'acknowledged') {
// Calculate lookback days from search period
const searchPeriodDays = Math.ceil((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / (1000 * 60 * 60 * 24))
const lookbackDays = searchPeriodDays + 30
return (
<div
style={{ fontSize: '0.75rem', marginTop: '2px', color: config.color, cursor: 'help' }}
title={`Price stable over last ${lookbackDays} days\nClick 📊 to see full 12-month history`}
>
<span style={{ fontWeight: 'bold' }}>{config.icon}</span>
</div>
)
}
// Show icon + percentage for amber/red status with actual price changes
if ((item.price_change_status === 'amber' || item.price_change_status === 'red') && item.price_change_percent !== null && item.price_change_percent !== 0) {
const isIncrease = item.price_change_percent > 0
const arrow = isIncrease ? '▲' : '▼'
const color = isIncrease ? '#ef4444' : '#22c55e' // Red for increase, green for decrease
const direction = isIncrease ? 'increased' : 'decreased'
// Calculate lookback days from search period
const searchPeriodDays = Math.ceil((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / (1000 * 60 * 60 * 24))
const lookbackDays = searchPeriodDays + 30
return (
<div
style={{ fontSize: '0.75rem', marginTop: '2px', color, cursor: 'help' }}
title={`Price ${direction} ${Math.abs(item.price_change_percent).toFixed(1)}% over last ${lookbackDays} days\nClick 📊 to see full history`}
>
<span style={{ fontWeight: 'bold' }}>{arrow}</span>{' '}
{Math.abs(item.price_change_percent).toFixed(1)}%
</div>
)
}
// Show just the icon for amber/red without percentage
if (item.price_change_status === 'amber' || item.price_change_status === 'red') {
return (
<div style={{ fontSize: '0.75rem', marginTop: '2px', color: config.color }}>
<span style={{ fontWeight: 'bold' }}>{config.icon}</span>
</div>
)
}
return null
}
const formatRate = (item: LineItemSearchItem) => {
if (item.price_per_std_unit == null) return null
const rate = typeof item.price_per_std_unit === 'string' ? parseFloat(item.price_per_std_unit) : item.price_per_std_unit
const unit = item.ingredient_standard_unit || '?'
// Convert tiny per-g/ml prices to per-kg/ltr for readability
if (unit === 'g' && rate < 1) return `£${(rate * 1000).toFixed(2)}/kg`
if (unit === 'ml' && rate < 1) return `£${(rate * 1000).toFixed(2)}/ltr`
// For prices >= £1 show 2 decimals, for smaller show 4
const decimals = rate >= 1 ? 2 : 4
return `£${rate.toFixed(decimals)}/${unit}`
}
const clearSearch = () => {
setSearchInput('')
setSupplierId('')
setPriceChangeFilter('')
setMappedFilter('')
setGroupBy('')
const defaultDates = getDefaultDateRange()
setDateFrom(defaultDates.from)
setDateTo(defaultDates.to)
}
const hasActiveFilters = searchInput || supplierId || priceChangeFilter || mappedFilter || groupBy
return (
<div style={{ padding: '20px', maxWidth: '1600px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '20px' }}>Search Line Items</h1>
{/* Search Controls */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '20px',
padding: '16px',
backgroundColor: '#f8fafc',
borderRadius: '8px',
}}
>
<input
type="text"
placeholder="Search product code, description..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{
flex: '1 1 300px',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px',
}}
/>
<select
value={supplierId}
onChange={(e) => setSupplierId(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Suppliers</option>
{suppliers?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
value={priceChangeFilter}
onChange={(e) => setPriceChangeFilter(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Price Changes</option>
<option value="no_history">No History</option>
<option value="no_change"> No Change</option>
<option value="increase"> Price Increase</option>
<option value="decrease"> Price Decrease</option>
</select>
<select
value={mappedFilter}
onChange={(e) => setMappedFilter(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Mapping</option>
<option value="yes">Mapped to Ingredient</option>
<option value="no">Not Mapped</option>
</select>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">No Grouping</option>
<option value="supplier">Group by Supplier</option>
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label style={{ fontSize: '14px' }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
<label style={{ fontSize: '14px' }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
</div>
{hasActiveFilters && (
<button
onClick={clearSearch}
style={{
padding: '8px 16px',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Clear Filters
</button>
)}
</div>
{/* Legend */}
<div
style={{
display: 'flex',
gap: '16px',
marginBottom: '16px',
fontSize: '13px',
color: '#6b7280',
flexWrap: 'wrap',
}}
>
<span>
<span style={{ color: '#22c55e', fontWeight: 'bold' }}></span> Price consistent
</span>
<span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}></span> Price increase
</span>
<span>
<span style={{ color: '#22c55e', fontWeight: 'bold' }}></span> Price decrease
</span>
<span>
<span style={{ color: '#28a745' }}></span> Mapped to ingredient
</span>
<span>
<span style={{ color: '#ccc' }}></span> Not mapped
</span>
</div>
{/* Results */}
{isLoading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
{data && (
<div>
<p style={{ marginBottom: '12px', color: '#6b7280' }}>
Found {data.total_count} unique item{data.total_count !== 1 ? 's' : ''}
{(priceChangeFilter || mappedFilter) && ` (showing ${sortedItems.length} filtered)`}
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '1000px' }}>
<thead>
<tr style={{ backgroundColor: '#f1f5f9' }}>
<th style={thStyleClickable} onClick={() => handleSort('code')}>
Code{getSortIndicator('code')}
</th>
<th style={{ ...thStyleClickable, minWidth: '150px' }} onClick={() => handleSort('description')}>
Description{getSortIndicator('description')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
Supplier{getSortIndicator('supplier')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('price')}>
Price{getSortIndicator('price')}
</th>
<th style={{ ...thStyle, textAlign: 'center', width: '40px' }}>
</th>
<th style={{ ...thStyleClickable, textAlign: 'right' }} onClick={() => handleSort('ingredient_rate')}>
Ingredient Rate{getSortIndicator('ingredient_rate')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('total_quantity')}>
Total Qty{getSortIndicator('total_quantity')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('occurrence_count')}>
#{getSortIndicator('occurrence_count')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('last_invoice')}>
Last Invoice{getSortIndicator('last_invoice')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('last_date')}>
Last Date{getSortIndicator('last_date')}
</th>
</tr>
</thead>
<tbody>
{sortedItems.map((item, idx) => (
<tr
key={`${item.product_code}-${item.description}-${item.supplier_id}-${idx}`}
style={{ borderBottom: '1px solid #e5e7eb' }}
>
<td style={tdStyle}>{item.product_code || '-'}</td>
<td style={tdStyle}>
<div>
{item.description || '-'}
{item.has_definition && (
<span
title={`${item.portions_per_unit || '?'} portions per unit`}
style={{ marginLeft: '6px', color: '#8b5cf6' }}
>
📦
</span>
)}
</div>
{!item.ingredient_id && !item.product_code && item.description && item.supplier_id && (() => {
const key = `${item.supplier_id}:${item.description.toLowerCase()}`
const suggestion = aliasSuggestions[key]
if (!suggestion) return null
const isAdding = addingAliasFor === key
return (
<div style={{
fontSize: '0.7rem',
color: '#0c5460',
background: '#d1ecf1',
padding: '0.2rem 0.4rem',
borderRadius: '3px',
marginTop: '3px',
border: '1px solid #bee5eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.4rem',
}}>
<span>
Similar to "{suggestion.canonical_description}"
{suggestion.ingredient_name && <> {suggestion.ingredient_name}</>}
{' '}({Math.round(suggestion.similarity * 100)}%)
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setAddingAliasFor(key)
addDescriptionAliasMutation.mutate({
sourceId: suggestion.source_id,
alias: item.description!,
})
}}
disabled={isAdding}
style={{
padding: '0.15rem 0.4rem',
background: '#17a2b8',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: isAdding ? 'default' : 'pointer',
fontSize: '0.65rem',
fontWeight: '500',
whiteSpace: 'nowrap' as const,
opacity: isAdding ? 0.6 : 1,
}}
>
{isAdding ? 'Adding...' : '+ Alias'}
</button>
</div>
)
})()}
</td>
<td style={tdStyle}>{item.supplier_name || '-'}</td>
<td style={tdStyle}>
<div>
<div>
{formatCurrency(item.most_recent_price)}
<span
onClick={() => openHistoryModal(item)}
style={{
marginLeft: '6px',
cursor: 'pointer',
}}
title="View price history"
>
📊
</span>
</div>
{renderPriceStatus(item)}
</div>
</td>
<td style={{ ...tdStyle, textAlign: 'center', padding: '4px' }}>
<button
type="button"
onClick={() => handleScaleClick(item)}
style={{
padding: '4px 6px',
background: 'transparent',
border: `1px solid ${item.ingredient_id ? '#28a745' : '#ddd'}`,
borderRadius: '4px',
cursor: 'pointer',
color: item.ingredient_id ? '#28a745' : '#999',
fontSize: '16px',
lineHeight: 1,
...(item.ingredient_id ? { background: '#e8f5e9' } : {}),
}}
title={
item.ingredient_id
? `Mapped to: ${item.ingredient_name}\nClick to view/edit mapping`
: 'Click to map to an ingredient'
}
>
</button>
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{item.ingredient_id ? (
<div>
<span style={{ color: '#16a34a', fontWeight: 600 }}>
{formatRate(item) || '-'}
</span>
<div style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '2px' }}>
{item.ingredient_name}
</div>
</div>
) : (
<span style={{ color: '#d1d5db' }}>-</span>
)}
</td>
<td style={tdStyle}>{formatQuantity(item.total_quantity)}</td>
<td style={tdStyle}>{item.occurrence_count}</td>
<td style={tdStyle}>
<a
href={`/kitchen/invoice/${item.most_recent_invoice_id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'none' }}
>
{item.most_recent_invoice_number || '-'}
</a>
</td>
<td style={tdStyle}>{formatDateForDisplay(item.most_recent_date)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* History Modal */}
{historyModal && (
<LineItemHistoryModal
isOpen={historyModal.isOpen}
onClose={() => setHistoryModal(null)}
productCode={historyModal.productCode}
description={historyModal.description}
unit={historyModal.unit}
supplierId={historyModal.supplierId}
supplierName={historyModal.supplierName}
/>
)}
{/* Ingredient Mapping Modal (same as Review.tsx) */}
{modalItem && (
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={closeModal}
>
<div style={{ background: '#fff', borderRadius: '12px', padding: '1.5rem', width: '650px', maxWidth: '95vw', maxHeight: '90vh', overflowY: 'auto' }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 style={{ margin: 0 }}>Ingredient Mapping</h3>
<button onClick={closeModal} style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer', color: '#666' }}>&times;</button>
</div>
{/* Line item context */}
<div style={{ padding: '0.75rem', background: '#f8f9fa', borderRadius: '6px', marginBottom: '1rem', fontSize: '0.9rem' }}>
<strong>{modalItem.description || modalItem.product_code || 'Unknown item'}</strong>
{modalItem.supplier_name && <span style={{ marginLeft: '0.75rem', color: '#666' }}>({modalItem.supplier_name})</span>}
{modalItem.most_recent_price != null && <span style={{ marginLeft: '0.75rem', color: '#666' }}>£{Number(modalItem.most_recent_price).toFixed(2)}</span>}
</div>
{/* Ingredient mapping section */}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: 600, fontSize: '0.9rem', display: 'block', marginBottom: '0.5rem' }}>
Ingredient {selectedIngredientId && <span style={{ color: '#28a745', fontWeight: 'normal' }}>({selectedIngredientName})</span>}
</label>
{selectedIngredientId ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: '#d4edda', borderRadius: '6px', border: '1px solid #c3e6cb' }}>
<span style={{ flex: 1 }}>{selectedIngredientName} <span style={{ color: '#666', fontSize: '0.85rem' }}>({selectedIngredientUnit})</span></span>
<button
onClick={() => { setSelectedIngredientId(null); setSelectedIngredientName(''); setSelectedIngredientUnit(''); setConversionDisplay('') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#666', fontSize: '1.1rem' }}
>&times;</button>
</div>
) : (
<>
<div style={{ position: 'relative' }}>
<input
type="text"
value={ingredientSearch}
onChange={(e) => { setIngredientSearch(e.target.value); searchIngredients(e.target.value) }}
placeholder="Search ingredients..."
style={{ padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: '4px', fontSize: '14px', width: '100%', boxSizing: 'border-box' }}
/>
{ingredientSearchLoading && <span style={{ position: 'absolute', right: '10px', top: '10px', color: '#999' }}>...</span>}
</div>
{ingredientSuggestions.length > 0 && (
<div style={{ border: '1px solid #ddd', borderRadius: '0 0 6px 6px', maxHeight: '200px', overflowY: 'auto', background: '#fff' }}>
{ingredientSuggestions.map((s) => (
<div
key={s.id}
onClick={() => selectIngredient(s)}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem', display: 'flex', justifyContent: 'space-between' }}
onMouseOver={(e) => (e.currentTarget.style.background = '#f0f7ff')}
onMouseOut={(e) => (e.currentTarget.style.background = '#fff')}
>
<span>{s.name} <span style={{ color: '#999', fontSize: '0.8rem' }}>({s.standard_unit})</span></span>
<span style={{ color: '#999', fontSize: '0.8rem' }}>{s.category_name || ''} {s.similarity ? `${(s.similarity * 100).toFixed(0)}%` : ''}</span>
</div>
))}
</div>
)}
<button
onClick={() => setShowCreateIngredient(true)}
style={{ marginTop: '0.5rem', padding: '0.4rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem' }}
>
+ Create new ingredient
</button>
</>
)}
</div>
{/* Unit conversion fields */}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: 600, fontSize: '0.9rem', display: 'block', marginBottom: '0.25rem' }}>
{selectedIngredientId ? `How much ${selectedIngredientName} is in this line item?` : 'Unit size & pricing'}
</label>
<div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
{modalItem.description}{modalItem.most_recent_price != null ? ` @ £${Number(modalItem.most_recent_price).toFixed(2)}` : ''}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: '0.8rem', color: '#666', display: 'block' }}>Contains</label>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="number" step="0.1"
value={costEdits.unit_size || ''}
onChange={(e) => { const v = { ...costEdits, unit_size: parseFloat(e.target.value) || null }; setCostEdits(v); updateConversionDisplay(undefined, v) }}
onFocus={(e) => e.target.select()}
style={{ padding: '6px 8px', border: '1px solid #d1d5db', borderRadius: '4px', width: '70px', fontSize: '14px' }}
placeholder="e.g., 2"
/>
<select
value={costEdits.unit_size_type || selectedIngredientUnit || ''}
onChange={(e) => { const v = { ...costEdits, unit_size_type: e.target.value || null }; setCostEdits(v); updateConversionDisplay(undefined, v) }}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px', width: '60px', fontSize: '14px' }}
>
<option value="">--</option>
<option value="each">each</option>
<option value="g">g</option>
<option value="kg">kg</option>
<option value="ml">ml</option>
<option value="ltr">ltr</option>
<option value="oz">oz</option>
<option value="cl">cl</option>
</select>
{selectedIngredientUnit && costEdits.unit_size_type && costEdits.unit_size_type !== selectedIngredientUnit && (
<span style={{ fontSize: '0.75rem', color: '#999' }}> {selectedIngredientUnit}</span>
)}
</div>
</div>
<div>
<label style={{ fontSize: '0.8rem', color: '#666', display: 'block' }}>Pack of</label>
<input
type="number"
value={costEdits.pack_quantity || ''}
onChange={(e) => { const v = { ...costEdits, pack_quantity: parseInt(e.target.value) || null }; setCostEdits(v); updateConversionDisplay(undefined, v) }}
onFocus={(e) => e.target.select()}
style={{ padding: '6px 8px', border: '1px solid #d1d5db', borderRadius: '4px', width: '50px', fontSize: '14px' }}
placeholder="1"
/>
</div>
<div>
<label style={{ fontSize: '0.8rem', color: '#666', display: 'block' }}>Line Price</label>
<span style={{ fontSize: '0.9rem', fontWeight: 500 }}>
{costEdits.unit_price ? `£${Number(costEdits.unit_price).toFixed(2)}` : '--'}
</span>
</div>
</div>
{conversionDisplay && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.9rem', color: '#2e7d32', fontWeight: 500 }}>
{conversionDisplay}
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button onClick={closeModal} style={{ padding: '0.5rem 1.25rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer' }}>Cancel</button>
<button
onClick={saveMapping}
disabled={savingMapping}
style={{ padding: '0.5rem 1.25rem', background: '#28a745', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', opacity: savingMapping ? 0.6 : 1 }}
>
{savingMapping ? 'Saving...' : selectedIngredientId ? 'Save & Map Ingredient' : 'Save'}
</button>
</div>
</div>
</div>
)}
<IngredientModal
open={showCreateIngredient}
onClose={() => setShowCreateIngredient(false)}
onSaved={handleIngredientCreated}
prePopulateName={ingredientSearch || ''}
preSelectLineItem={modalItem ? {
product_code: modalItem.product_code,
description: modalItem.description,
supplier_id: modalItem.supplier_id,
supplier_name: modalItem.supplier_name,
unit: modalItem.unit,
most_recent_price: modalItem.most_recent_price != null ? Number(modalItem.most_recent_price) : null,
total_quantity: modalItem.total_quantity,
occurrence_count: modalItem.occurrence_count,
most_recent_invoice_id: modalItem.most_recent_invoice_id,
most_recent_line_number: modalItem.most_recent_line_number,
most_recent_pack_quantity: modalItem.most_recent_pack_quantity,
most_recent_unit_size: modalItem.most_recent_unit_size,
most_recent_unit_size_type: modalItem.most_recent_unit_size_type,
ingredient_id: modalItem.ingredient_id,
ingredient_name: modalItem.ingredient_name,
} as LineItemResult : null}
/>
</div>
)
}
const thStyle: React.CSSProperties = {
padding: '12px',
textAlign: 'left',
fontWeight: 600,
fontSize: '14px',
}
const thStyleClickable: React.CSSProperties = {
...thStyle,
cursor: 'pointer',
userSelect: 'none',
}
const tdStyle: React.CSSProperties = {
padding: '12px',
fontSize: '14px',
}