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>
1139 lines
47 KiB
TypeScript
1139 lines
47 KiB
TypeScript
/**
|
||
* 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' }}>×</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' }}
|
||
>×</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',
|
||
}
|