/** * Line Item History Modal * Shows price history chart and quantity stats for a product. * Reusable component for search pages and invoice review. */ import { useState, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useAuth } from '../App' import { formatCurrency, formatDateForDisplay, formatPercent, formatQuantity } from '../utils/searchHelpers' interface PriceHistoryPoint { date: string price: number invoice_id: number invoice_number: string | null quantity: number | null } interface HistoryResponse { product_code: string | null description: string | null supplier_id: number supplier_name: string | null price_history: PriceHistoryPoint[] total_occurrences: number total_quantity: number avg_qty_per_invoice: number avg_qty_per_week: number avg_qty_per_month: number current_price: number | null price_change_status: string } interface Props { isOpen: boolean onClose: () => void productCode: string | null description: string | null unit: string | null supplierId: number supplierName: string currentPrice?: number sourceInvoiceId?: number sourceLineItemId?: number onAcknowledge?: () => void } export default function LineItemHistoryModal({ isOpen, onClose, productCode, description, unit, supplierId, supplierName, currentPrice, sourceInvoiceId, sourceLineItemId, onAcknowledge, }: Props) { const { token } = useAuth() const queryClient = useQueryClient() // Date range state (default: 12 months) const defaultDateRange = useMemo(() => { const today = new Date() const yearAgo = new Date(today) yearAgo.setFullYear(yearAgo.getFullYear() - 1) return { from: yearAgo.toISOString().split('T')[0], to: today.toISOString().split('T')[0], } }, []) const [dateFrom, setDateFrom] = useState(defaultDateRange.from) const [dateTo, setDateTo] = useState(defaultDateRange.to) // Fetch history const { data, isLoading, error } = useQuery({ queryKey: ['line-item-history', supplierId, productCode, description, unit, dateFrom, dateTo], queryFn: async () => { const params = new URLSearchParams() params.set('supplier_id', supplierId.toString()) if (productCode) params.set('product_code', productCode) if (description) params.set('description', description) if (unit) params.set('unit', unit) if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) const res = await fetch(`/kitchen/api/search/line-items/history?${params}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch history') return res.json() }, enabled: isOpen && !!token, }) // Acknowledge price mutation const acknowledgeMutation = useMutation({ mutationFn: async () => { const res = await fetch('/kitchen/api/search/line-items/acknowledge-price', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ product_code: productCode, description: description, supplier_id: supplierId, new_price: currentPrice || data?.current_price, source_invoice_id: sourceInvoiceId, source_line_item_id: sourceLineItemId, }), }) if (!res.ok) throw new Error('Failed to acknowledge price') return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['line-item-history'] }) queryClient.invalidateQueries({ queryKey: ['search-line-items'] }) if (onAcknowledge) onAcknowledge() }, }) // Calculate price range for chart (must be before early return to satisfy hooks rules) // Uses a minimum meaningful range to avoid misleading charts when prices are very similar const priceRange = useMemo(() => { if (!data?.price_history.length) return { min: 0, max: 100 } const prices = data.price_history.map((p) => typeof p.price === 'string' ? parseFloat(p.price) : p.price) const min = Math.min(...prices) const max = Math.max(...prices) const actualRange = max - min const avgPrice = (min + max) / 2 // Minimum range should be at least 20% of average price to give proper context // This prevents tiny variations from filling the whole chart const minMeaningfulRange = avgPrice * 0.2 || 1 const effectiveRange = Math.max(actualRange, minMeaningfulRange) // Center the range around the actual data const centerPrice = (min + max) / 2 const rangeMin = centerPrice - effectiveRange / 2 const rangeMax = centerPrice + effectiveRange / 2 // Add small padding const padding = effectiveRange * 0.1 return { min: Math.max(0, rangeMin - padding), max: rangeMax + padding } }, [data]) if (!isOpen) return null // Calculate chart height percentage for a price const getPriceHeight = (price: number) => { const range = priceRange.max - priceRange.min if (range === 0) return 50 return ((price - priceRange.min) / range) * 100 } const displayPrice = currentPrice ?? data?.current_price const showAcknowledge = data?.price_change_status === 'amber' || data?.price_change_status === 'red' return (
e.stopPropagation()} > {/* Header */}

Price History: {description || productCode || 'Unknown'} {productCode && description && ( ({productCode}) )}

Supplier: {supplierName}

{/* Date Range */}
setDateFrom(e.target.value)} style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }} /> to setDateTo(e.target.value)} style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }} />
{isLoading &&

Loading...

} {error &&

Error: {(error as Error).message}

} {data && ( <> {/* Price Chart (Simple bar visualization) */}

Price History

{data.price_history.length === 0 ? (

No price history available for this period

) : (
{/* Chart */}
{/* Y-axis labels */}
{formatCurrency(priceRange.max)} {formatCurrency(priceRange.min)}
{/* Bars */}
{data.price_history.map((point, idx) => (
))}
{/* X-axis (dates) */}
{data.price_history.length > 0 && ( <> {formatDateForDisplay(data.price_history[0].date)} {formatDateForDisplay( data.price_history[data.price_history.length - 1].date )} )}
{/* Price history table */}
{data.price_history .slice() .reverse() .map((point, idx) => ( ))}
Date Description Unit Qty Price
{formatDateForDisplay(point.date)}
{data.description || '-'} {point.invoice_number || 'View'} ↗
{unit || '-'} {formatQuantity(point.quantity)} {formatCurrency(point.price)}
)}
{/* Stats */}

Stats for Period

{/* Current Price & Acknowledge */} {displayPrice && (
Current Price: {formatCurrency(displayPrice)} {data.price_history.length > 1 && ( {(() => { const prev = data.price_history[data.price_history.length - 2]?.price if (!prev) return '' const change = ((displayPrice - prev) / prev) * 100 return `(${formatPercent(change)} from previous ${formatCurrency(prev)})` })()} )}
{showAcknowledge && ( )}
{acknowledgeMutation.isSuccess && (

✓ Price change acknowledged

)}
)} )}
) } function StatItem({ label, value }: { label: string; value: string }) { return (
{label}
{value}
) }