import React, { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useAuth } from '../App' import * as pdfjsLib from 'pdfjs-dist' import { AnnotationMode } from 'pdfjs-dist' import LineItemHistoryModal from './LineItemHistoryModal' import CreateDisputeModal from './CreateDisputeModal' import DisputeDetailModal from './DisputeDetailModal' import LinkDisputeModal from './LinkDisputeModal' import PurchaseOrderModal from './PurchaseOrderModal' import IngredientModal from './IngredientModal' import { IngredientModalResult, LineItemResult } from '../utils/ingredientHelpers' // Use local worker file from public directory pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs' // Simple Scale icon SVG component const ScaleIcon = ({ style }: { style?: React.CSSProperties }) => ( ) // Source badge component - shows where invoice came from const sourceConfig: Record = { upload: { label: 'Upload', color: '#6c757d', icon: '📤' }, email: { label: 'Email', color: '#3498db', icon: '📧' }, api: { label: 'API', color: '#9b59b6', icon: '🔌' }, } const SourceBadge = ({ source, sourceReference }: { source: string; sourceReference?: string | null }) => { const { label, color, icon } = sourceConfig[source] || sourceConfig.upload return ( {icon} {label} ) } interface Invoice { id: number invoice_number: string | null invoice_date: string | null total: number | null net_total: number | null stock_total: number | null supplier_id: number | null supplier_name: string | null supplier_match_type: string | null // "exact", "fuzzy", or null supplier_skip_dext: boolean // Whether supplier has skip_dext enabled vendor_name: string | null // OCR-extracted vendor name status: string category: string | null ocr_confidence: number | null ocr_raw_text: string | null // OCR text or error message image_path: string document_type: string | null order_number: string | null duplicate_status: string | null duplicate_of_id: number | null // Dext integration notes: string | null dext_sent_at: string | null dext_sent_by_username: string | null // Dispute tracking dispute_count: number has_open_disputes: boolean disputes: Array<{ id: number dispute_type: string status: string title: string disputed_amount: number opened_at: string }> disputed_line_item_ids: number[] // Source tracking source: string source_reference: string | null // Linked dispute (for credit notes) linked_dispute_id: number | null // Total pages from OCR (for multi-page invoices) total_pages: number | null } interface Supplier { id: number name: string aliases?: string[] } interface LineItem { id: number product_code: string | null description: string | null description_alt: string | null // Alternative description (Azure content vs value mismatch) unit: string | null quantity: number | null order_quantity: number | null unit_price: number | null tax_rate: string | null tax_amount: number | null amount: number | null line_number: number is_non_stock: boolean // Pack size fields raw_content: string | null pack_quantity: number | null unit_size: number | null unit_size_type: string | null portions_per_unit: number | null // null = not defined yet cost_per_item: number | null cost_per_portion: number | null // OCR warnings for values that needed correction ocr_warnings: string | null // Price change tracking price_change_status: string | null // "consistent", "amber", "red", "no_history", "acknowledged" price_change_percent: number | null previous_price: number | null // Future price (for old invoices) future_price: number | null future_change_percent: number | null // Page number from OCR (for multi-page invoices) page_number: number | null // Ingredient mapping ingredient_id?: number | null ingredient_name?: string | null ingredient_unit?: string | null } // Ingredient types for mapping modal interface IngredientSuggestion { id: number name: string category_name: string | null standard_unit: string yield_percent: number similarity?: number } // Scale icon color based on data completeness const getScaleIconColor = (item: LineItem): string => { if (item.ingredient_id) return '#28a745' // Green - mapped to ingredient if (!item.pack_quantity) return '#dc3545' // Red - no pack data if (item.portions_per_unit === null) return '#ffc107' // Amber - portions not defined if (item.cost_per_portion !== null) return '#28a745' // Green - fully complete return '#ffc107' // Amber - fallback } interface DuplicateCompare { current_invoice: Invoice firm_duplicate: Invoice | null possible_duplicates: Invoice[] related_documents: Invoice[] } interface ProductDefinition { id: number kitchen_id: number supplier_id: number | null product_code: string | null description_pattern: string | null pack_quantity: number | null unit_size: number | null unit_size_type: string | null portions_per_unit: number | null portion_description: string | null saved_by_user_id: number | null saved_by_username: string | null source_invoice_id: number | null source_invoice_number: string | null updated_at: string | null } interface Settings { high_quantity_threshold: number dext_manual_send_enabled: boolean pdf_preview_show_annotations: boolean // LLM FEATURE — see LLM-MANIFEST.md for removal instructions llm_enabled?: boolean anthropic_api_key_set?: boolean } // LLM FEATURE — see LLM-MANIFEST.md for removal instructions interface AiAssistSuggestions { supplier_match?: { id: number; name: string; confidence: number; reason: string } | null corrected_date?: string | null line_item_corrections?: Array<{ idx: number; field: string; current: number | string | null; suggested: number | string; reason: string }> description_recommendations?: Array<{ idx: number; recommendation: string; reason: string }> subtotal_flags?: number[] total_mismatch_analysis?: string | null vat_treatment?: string | null pack_size_suggestions?: Array<{ idx: number; pack_quantity: number; unit_size: number; unit_size_type: string }> } interface AiReconciliationMatch { idx: number ingredient_id: number ingredient_name: string confidence: number reason: string } interface SearchResultItem { description: string unit_price: number | null unit: string | null pack_info: string | null last_invoice_date: string | null invoice_id: number similarity: number } interface SupplierSearchGroup { supplier_id: number | null supplier_name: string items: SearchResultItem[] } interface SearchResponse { query: string extracted_keywords: string results: SupplierSearchGroup[] total_matches: number } const TOLERANCE = 0.02; // 2p tolerance for rounding // Helper to get first line of description (before newline) const getFirstLineOfDescription = (description: string | null): string => { if (!description) return ''; return description.split('\n')[0].trim(); }; // Date warning levels for unconfirmed invoices type DateWarning = 'none' | 'amber' | 'red' | 'future'; function getDateWarning(dateStr: string | null, status: string): DateWarning { // No date = always red (regardless of status) if (!dateStr) return 'red'; // Only warn for unconfirmed invoices (confirmed invoices can have any date) if (status === 'CONFIRMED') return 'none'; const invoiceDate = new Date(dateStr); const today = new Date(); today.setHours(0, 0, 0, 0); invoiceDate.setHours(0, 0, 0, 0); // Future date = almost certainly a day/month swap error — highest priority if (invoiceDate > today) return 'future'; const diffMs = today.getTime() - invoiceDate.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); // More than 7 days old: escalate based on how far back and whether it crosses month/year if (diffDays > 7) { const sameMonth = invoiceDate.getMonth() === today.getMonth(); const sameYear = invoiceDate.getFullYear() === today.getFullYear(); if (!sameMonth || !sameYear) { // Dec-Jan (and general month crossover) grace period — amber up to 45 days // so a late-December invoice reviewed in early January isn't immediately red if (diffDays <= 45) return 'amber'; return 'red'; } return 'amber'; // Same month/year, just a bit old = amber } return 'none'; } const dateWarningStyles: Record = { none: {}, amber: { backgroundColor: '#fff3cd', borderColor: '#ffc107' }, red: { backgroundColor: '#f8d7da', borderColor: '#dc3545' }, future: { backgroundColor: '#ffe0cc', borderColor: '#e85d04', outline: '2px solid #e85d04' }, }; // Returns the day/month swapped version of a yyyy-mm-dd string, or null if invalid function getSwappedDate(dateStr: string): string | null { const parts = dateStr.split('-') if (parts.length !== 3) return null const [year, month, day] = parts const newMonth = parseInt(day, 10) const newDay = parseInt(month, 10) if (newMonth < 1 || newMonth > 12 || newDay < 1 || newDay > 31) return null const swapped = `${year}-${String(newMonth).padStart(2, '0')}-${String(newDay).padStart(2, '0')}` const d = new Date(swapped) if (isNaN(d.getTime())) return null return swapped } // Returns the most plausible year-corrected version of a future date (same month/day, current or previous year). // Only triggers when the date is >60 days in the future — invoices dated within ~2 months ahead could // legitimately be a Dec-Jan crossover, so we don't want to aggressively suggest a year correction there. function getYearCorrectedDate(dateStr: string): { date: string; year: number } | null { const parts = dateStr.split('-') if (parts.length !== 3) return null const [yearStr, month, day] = parts const today = new Date(); today.setHours(0, 0, 0, 0) const currentYear = today.getFullYear() if (parseInt(yearStr, 10) <= currentYear) return null // year is not wrong // Only suggest a year correction when clearly beyond the Dec-Jan overlap window const daysAhead = Math.floor((new Date(dateStr).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) if (daysAhead <= 60) return null // Try current year first, then previous year — take the first that's a valid past/today date for (const y of [currentYear, currentYear - 1]) { const candidate = `${y}-${month}-${day}` const d = new Date(candidate) if (!isNaN(d.getTime()) && d <= today) return { date: candidate, year: y } } return null } function LineItemsValidation({ lineItems, invoiceTotal, netTotal }: { lineItems: LineItem[]; invoiceTotal: number; netTotal: number | null }) { // Handle case when there are no line items if (lineItems.length === 0) { return (
⚠️ No Line Items
This invoice has no line items. It will NOT be included in GP calculations. Use the "Edit Line Items" button above to manually add line items.
Invoice Total: £{invoiceTotal.toFixed(2)}
); } const lineItemsTotal = lineItems.reduce((sum, item) => sum + (item.amount || 0), 0); const stockItemsTotal = lineItems .filter(item => !item.is_non_stock) .reduce((sum, item) => sum + (item.amount || 0), 0); const nonStockItemsTotal = lineItems .filter(item => item.is_non_stock) .reduce((sum, item) => sum + (item.amount || 0), 0); // Line item amounts are net values, so compare against netTotal (if available) const compareTotal = netTotal ?? invoiceTotal; const difference = Math.abs(compareTotal - lineItemsTotal); const exactMatch = difference <= TOLERANCE; const isValid = exactMatch; const hasNonStock = nonStockItemsTotal > 0; return (
Line Items Total: £{lineItemsTotal.toFixed(2)} Invoice Net: £{(netTotal ?? invoiceTotal).toFixed(2)} {netTotal && (Gross: £{invoiceTotal.toFixed(2)})}
{hasNonStock && (
Stock Items: £{stockItemsTotal.toFixed(2)} Non-Stock: £{nonStockItemsTotal.toFixed(2)}
GP will be calculated using stock items only
)} {exactMatch ? (
✓ Totals match
) : (
{isValid ? '✓ ' : '⚠ '}Difference: £{difference.toFixed(2)}
)}
); } export default function Review() { const { id } = useParams() const { token, user } = useAuth() const navigate = useNavigate() const queryClient = useQueryClient() const [invoiceNumber, setInvoiceNumber] = useState('') const [invoiceDate, setInvoiceDate] = useState('') const [total, setTotal] = useState('') const [netTotal, setNetTotal] = useState('') const [category, setCategory] = useState('food') const [orderNumber, setOrderNumber] = useState('') const [documentType, setDocumentType] = useState('invoice') const [supplierId, setSupplierId] = useState('') const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showCreateSupplierModal, setShowCreateSupplierModal] = useState(false) const [showDisputeModal, setShowDisputeModal] = useState(false) const [viewingDisputeId, setViewingDisputeId] = useState(null) const [adminOperationInProgress, setAdminOperationInProgress] = useState(false) const [adminOperationResult, setAdminOperationResult] = useState<{type: 'success' | 'error', message: string} | null>(null) const [showRawOcrModal, setShowRawOcrModal] = useState(false) const [showSearchModal, setShowSearchModal] = useState(false) const [showBulkStockModal, setShowBulkStockModal] = useState(false) const [searchingLineItem, setSearchingLineItem] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState(null) const [searchLoading, setSearchLoading] = useState(false) const [newSupplierName, setNewSupplierName] = useState('') const [editingLineItem, setEditingLineItem] = useState(null) const [lineItemEdits, setLineItemEdits] = useState>({}) const [bulkEditMode, setBulkEditMode] = useState(false) const [highlightedField, setHighlightedField] = useState(null) const [expandedLineItem, setExpandedLineItem] = useState(null) const [costBreakdownEdits, setCostBreakdownEdits] = useState>({}) const [descSwapItem, setDescSwapItem] = useState(null) // For description swap modal const [portionDescription, setPortionDescription] = useState('') const [saveAsDefault, setSaveAsDefault] = useState(false) const [currentDefinition, setCurrentDefinition] = useState(null) const [definitionLoading, setDefinitionLoading] = useState(false) const [pdfPages, setPdfPages] = useState<{ width: number; height: number; displayWidth: number; displayHeight: number; canvas: HTMLCanvasElement }[]>([]) const [zoomLevel, setZoomLevel] = useState(1) const [zoomPageNum, setZoomPageNum] = useState(0) // Which page the zoom applies to const [imageZoom, setImageZoom] = useState(1) // Zoom level for non-PDF images const pdfContainerRef = useRef(null) const containerRef = useRef(null) const imageContainerRef = useRef(null) const highlightRef = useRef(null) // Dext integration state const [invoiceNotes, setInvoiceNotes] = useState('') const [showDextSendConfirm, setShowDextSendConfirm] = useState(false) const [showLinkDisputeModal, setShowLinkDisputeModal] = useState(false) // Date picker modal state const [showDatePickerModal, setShowDatePickerModal] = useState(false) const [parsedDates, setParsedDates] = useState>([]) const [parsedDatesLoading, setParsedDatesLoading] = useState(false) // Non-stock confirmation modal state const [showNonStockConfirmModal, setShowNonStockConfirmModal] = useState(false) const [nonStockConflictItems, setNonStockConflictItems] = useState>([]) // Invoice number search modal state const [showInvoiceNumberModal, setShowInvoiceNumberModal] = useState(false) const [invoiceNumberCandidates, setInvoiceNumberCandidates] = useState>([]) const [invoiceNumberExamples, setInvoiceNumberExamples] = useState([]) const [invoiceNumberLoading, setInvoiceNumberLoading] = useState(false) // Line items sorting and filtering const [lineItemSortColumn, setLineItemSortColumn] = useState('') const [lineItemSortDirection, setLineItemSortDirection] = useState<'asc' | 'desc'>('asc') const [lineItemPriceFilter, setLineItemPriceFilter] = useState('') const [lineItemSearchText, setLineItemSearchText] = useState('') const [lineItemPortionsFilter, setLineItemPortionsFilter] = useState('') const [lineItemMissingDataFilter, setLineItemMissingDataFilter] = useState('') // Current visible page for sticky indicator (multi-page invoices) const [currentVisiblePage, setCurrentVisiblePage] = useState(1) const lineItemsTableRef = useRef(null) // Ingredient mapping modal state const [ingredientModalItem, setIngredientModalItem] = useState(null) const [ingredientSearch, setIngredientSearch] = useState('') const [ingredientSuggestions, setIngredientSuggestions] = useState([]) const [ingredientSearchLoading, setIngredientSearchLoading] = useState(false) const [selectedIngredientId, setSelectedIngredientId] = useState(null) const [selectedIngredientName, setSelectedIngredientName] = useState('') const [selectedIngredientUnit, setSelectedIngredientUnit] = useState('') const [showCreateIngredient, setShowCreateIngredient] = useState(false) const [showLegacyPortioning, setShowLegacyPortioning] = useState(false) const [ingredientConversionDisplay, setIngredientConversionDisplay] = useState('') // Description alias suggestion state const [aliasSuggestions, setAliasSuggestions] = useState>({}) const [addingAliasFor, setAddingAliasFor] = useState(null) // LLM FEATURE — AI Assist state — see LLM-MANIFEST.md for removal instructions const [aiMatchLoading, setAiMatchLoading] = useState(false) const [aiMatchResults, setAiMatchResults] = useState>([]) const [aiAssistLoading, setAiAssistLoading] = useState(false) const [aiAssistSuggestions, setAiAssistSuggestions] = useState(null) const [aiReconciliationMatches, setAiReconciliationMatches] = useState([]) const [aiAssistError, setAiAssistError] = useState(null) const [aiDismissedCorrections, setAiDismissedCorrections] = useState>(new Set()) const [aiPackSizeLoading, setAiPackSizeLoading] = useState(false) const [aiPackSizeSource, setAiPackSizeSource] = useState(null) // Price history modal state const [historyModal, setHistoryModal] = useState<{ isOpen: boolean productCode: string | null description: string | null unit: string | null supplierId: number supplierName: string currentPrice?: number sourceInvoiceId?: number sourceLineItemId?: number } | null>(null) // Purchase Order matching state const [viewingPoId, setViewingPoId] = useState(null) const { data: invoice, isLoading, refetch: refetchInvoice } = useQuery({ queryKey: ['invoice', id], queryFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch invoice') return res.json() }, staleTime: 30000, refetchOnWindowFocus: false, }) // Direct URL with token - simpler approach const imageUrl = invoice ? `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}#toolbar=0&navpanes=0&view=FitH` : null const { data: lineItems, refetch: refetchLineItems } = useQuery({ queryKey: ['invoice-line-items', id], queryFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch line items') return res.json() }, }) const { data: stockHistory } = useQuery>({ queryKey: ['invoice-stock-history', id], queryFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}/stock-history`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch stock history') return res.json() }, enabled: !!id, }) const { data: duplicateInfo } = useQuery({ queryKey: ['invoice-duplicates', id], queryFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}/duplicates`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch duplicates') return res.json() }, enabled: !!invoice?.duplicate_status, }) const { data: rawOcrData } = useQuery<{ raw_json: any; raw_text: string }>({ queryKey: ['invoice-ocr-data', id], queryFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}/ocr-data`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch OCR data') return res.json() }, }) // Fetch settings for high quantity threshold const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: async () => { const res = await fetch('/kitchen/api/settings/', { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch settings') return res.json() }, staleTime: 60000, // Cache for 1 minute }) // Fetch description alias suggestions for unmapped line items without product codes useEffect(() => { if (!lineItems || !supplierId || !token) return const unmappedItems = lineItems.filter(li => !li.ingredient_id && !li.product_code && li.description) if (unmappedItems.length === 0) { setAliasSuggestions({}) return } const items = unmappedItems.map(li => ({ description: li.description!.split('\n')[0].trim(), price: li.unit_price ?? undefined, })) // Deduplicate by description const uniqueItems = Array.from(new Map(items.map(i => [i.description.toLowerCase(), i])).values()) if (uniqueItems.length === 0) return fetch('/kitchen/api/ingredients/sources/alias-suggestions', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ supplier_id: parseInt(supplierId), items: uniqueItems }), }) .then(res => res.ok ? res.json() : []) .then((suggestions: any[]) => { const map: typeof aliasSuggestions = {} for (const s of suggestions) { map[s.description.toLowerCase()] = s } setAliasSuggestions(map) }) .catch(() => setAliasSuggestions({})) }, [lineItems, supplierId, token]) // Helper to get bounding box for a field from raw OCR data const getFieldBoundingBox = (fieldName: string): { x: number; y: number; width: number; height: number; pageNumber: number } | null => { if (!rawOcrData?.raw_json?.documents?.[0]?.fields?.[fieldName]?.bounding_regions?.[0]) { return null } const region = rawOcrData.raw_json.documents[0].fields[fieldName].bounding_regions[0] const polygon = region.polygon const pageNumber = region.page_number || 1 // Azure uses 1-based page numbers if (!polygon || polygon.length < 4) return null // Get page dimensions for the correct page (Azure uses inches by default) const pageInfo = rawOcrData.raw_json.pages?.[pageNumber - 1] // Convert to 0-based const pageWidth = pageInfo?.width || 8.5 const pageHeight = pageInfo?.height || 11 // Convert polygon to bounding box (polygon is array of [x, y] pairs) const xs = polygon.map((p: number[]) => p[0]) const ys = polygon.map((p: number[]) => p[1]) const minX = Math.min(...xs) const maxX = Math.max(...xs) const minY = Math.min(...ys) const maxY = Math.max(...ys) // Return as percentages of page dimensions for easy scaling return { x: (minX / pageWidth) * 100, y: (minY / pageHeight) * 100, width: ((maxX - minX) / pageWidth) * 100, height: ((maxY - minY) / pageHeight) * 100, pageNumber, } } // Helper to get bounding box for a line item by index const getLineItemBoundingBox = (lineIndex: number): { x: number; y: number; width: number; height: number; pageNumber: number } | null => { if (!rawOcrData?.raw_json?.documents?.[0]?.fields?.Items?.value?.[lineIndex]?.bounding_regions?.[0]) { return null } const region = rawOcrData.raw_json.documents[0].fields.Items.value[lineIndex].bounding_regions[0] const polygon = region.polygon const pageNumber = region.page_number || 1 if (!polygon || polygon.length < 4) return null const pageInfo = rawOcrData.raw_json.pages?.[pageNumber - 1] const pageWidth = pageInfo?.width || 8.5 const pageHeight = pageInfo?.height || 11 const xs = polygon.map((p: number[]) => p[0]) const ys = polygon.map((p: number[]) => p[1]) const minX = Math.min(...xs) const maxX = Math.max(...xs) const minY = Math.min(...ys) const maxY = Math.max(...ys) return { x: (minX / pageWidth) * 100, y: (minY / pageHeight) * 100, width: ((maxX - minX) / pageWidth) * 100, height: ((maxY - minY) / pageHeight) * 100, pageNumber, } } // Crop a bounding-box region from the high-res PDF canvas and return a data URL. // Returns null for non-PDF documents or if the page hasn't rendered yet. const cropBboxToDataUrl = ( bbox: { x: number; y: number; width: number; height: number; pageNumber: number }, options?: { padX?: number; padY?: number; strokeColor?: string } ): string | null => { if (!invoice?.image_path?.toLowerCase().endsWith('.pdf') || pdfPages.length === 0) return null const pageData = pdfPages[bbox.pageNumber - 1] if (!pageData) return null const { padX = 60, padY = 20, strokeColor = '#007bff' } = options || {} const bboxX = (bbox.x / 100) * pageData.width const bboxY = (bbox.y / 100) * pageData.height const bboxW = (bbox.width / 100) * pageData.width const bboxH = (bbox.height / 100) * pageData.height const startX = Math.max(0, bboxX - padX) const startY = Math.max(0, bboxY - padY) const endX = Math.min(pageData.width, bboxX + bboxW + padX) const endY = Math.min(pageData.height, bboxY + bboxH + padY) const cropW = endX - startX const cropH = endY - startY const croppedCanvas = document.createElement('canvas') croppedCanvas.width = cropW croppedCanvas.height = cropH const ctx = croppedCanvas.getContext('2d') if (!ctx) return null ctx.drawImage(pageData.canvas, startX, startY, cropW, cropH, 0, 0, cropW, cropH) ctx.strokeStyle = strokeColor ctx.lineWidth = 3 ctx.strokeRect(bboxX - startX - 4, bboxY - startY - 4, bboxW + 8, bboxH + 8) return croppedCanvas.toDataURL() } // Calculate zoom level to make bounding box fill ~80% of container const calculateZoomForBbox = (bbox: { x: number; y: number; width: number; height: number; pageNumber: number } | null): number => { if (!bbox || !pdfContainerRef.current || pdfPages.length === 0) return 2 const pageData = pdfPages[bbox.pageNumber - 1] if (!pageData) return 2 // Get container dimensions const containerWidth = pdfContainerRef.current.clientWidth - 32 // padding const containerHeight = pdfContainerRef.current.clientHeight - 32 // Calculate bbox size in display pixels const bboxDisplayWidth = (bbox.width / 100) * pageData.displayWidth const bboxDisplayHeight = (bbox.height / 100) * pageData.displayHeight // Calculate zoom to make bbox fill 80% of container (use the more constraining dimension) const targetFill = 0.8 const zoomForWidth = (containerWidth * targetFill) / bboxDisplayWidth const zoomForHeight = (containerHeight * targetFill) / bboxDisplayHeight // Use the smaller zoom so it fits both dimensions, with min/max limits const zoom = Math.min(zoomForWidth, zoomForHeight) return Math.max(2, Math.min(zoom, 8)) // Clamp between 2x and 8x } // Scroll to page containing the bounding box and set zoom with centering const scrollToHighlight = (bbox: { x: number; y: number; width: number; height: number; pageNumber: number } | null, zoom: number) => { if (!bbox || !pdfContainerRef.current || pdfPages.length === 0) return const pageData = pdfPages[bbox.pageNumber - 1] if (!pageData) return // Set zoom level for target page directly setZoomLevel(zoom) setZoomPageNum(bbox.pageNumber) // Wait for DOM update, then scroll to centered position setTimeout(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { if (!pdfContainerRef.current) return const container = pdfContainerRef.current // Force layout recalculation void container.offsetHeight // Find the actual page element to get its real position const pageElements = container.querySelectorAll('[data-page-container]') const targetPageElement = pageElements[bbox.pageNumber - 1] as HTMLElement if (!targetPageElement) { console.warn('Could not find page element for scrolling', { totalPages: pdfPages.length, targetPage: bbox.pageNumber, foundElements: pageElements.length }) return } // Use getBoundingClientRect for accurate positioning const containerRect = container.getBoundingClientRect() const pageRect = targetPageElement.getBoundingClientRect() // Get the actual dimensions of the zoomed page const pageActualWidth = pageRect.width const pageActualHeight = pageRect.height // Calculate bbox center position in pixels (bbox coords are percentages of the page) const bboxCenterX = ((bbox.x + bbox.width / 2) / 100) * pageActualWidth const bboxCenterY = ((bbox.y + bbox.height / 2) / 100) * pageActualHeight // Calculate bbox center position relative to the container // pageRect.left/top are relative to viewport, containerRect.left/top are container position // Add current scroll position to get absolute position within scrollable content const pageLeftInContainer = pageRect.left - containerRect.left + container.scrollLeft const pageTopInContainer = pageRect.top - containerRect.top + container.scrollTop const bboxAbsoluteX = pageLeftInContainer + bboxCenterX const bboxAbsoluteY = pageTopInContainer + bboxCenterY // Container viewport dimensions (clientWidth/Height excludes scrollbars but includes padding) const containerWidth = container.clientWidth const containerHeight = container.clientHeight // Calculate scroll position to center the bbox in the viewport const scrollLeft = Math.max(0, bboxAbsoluteX - containerWidth / 2) const scrollTop = Math.max(0, bboxAbsoluteY - containerHeight / 2) // Scroll instantly to show the highlighted area centered container.scrollTo({ left: scrollLeft, top: scrollTop, behavior: 'auto' // Instant scroll instead of smooth }) }) }) }, 100) // Wait for zoom to apply and DOM to update } // Reset zoom const resetZoom = () => { setZoomLevel(1) setZoomPageNum(0) if (pdfContainerRef.current) { pdfContainerRef.current.scrollTo({ left: 0, top: 0, behavior: 'smooth' }) } } // Handler to toggle highlight and scroll to it const handleHighlightField = (fieldName: string) => { if (highlightedField === fieldName) { setHighlightedField(null) resetZoom() } else { setHighlightedField(fieldName) setExpandedLineItem(null) // Clear any line item highlight const bbox = getFieldBoundingBox(fieldName) const dynamicZoom = calculateZoomForBbox(bbox) scrollToHighlight(bbox, dynamicZoom) } } // Handler to toggle line item inline preview (no zoom/scroll for line items) const handleHighlightLineItem = (itemId: number, _lineIndex: number) => { if (expandedLineItem === itemId) { setExpandedLineItem(null) } else { setExpandedLineItem(itemId) setHighlightedField(null) // Clear any field highlight resetZoom() // Reset zoom when showing inline preview } } const { data: suppliers } = useQuery({ queryKey: ['suppliers'], queryFn: async () => { const res = await fetch('/kitchen/api/suppliers/', { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) return [] return res.json() }, }) // PO matching query — find linked or matching POs for this invoice const { data: poMatchData, refetch: refetchPoMatch } = useQuery<{ matches: Array<{ po_id: number order_date: string | null total_amount: number | null order_reference: string | null status: string order_type: string confidence: number }> linked_po: { po_id: number order_date: string | null total_amount: number | null order_reference: string | null status: string order_type: string } | null }>({ queryKey: ['po-match', id], queryFn: async () => { const res = await fetch(`/kitchen/api/purchase-orders/matching/for-invoice?invoice_id=${id}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) return { matches: [], linked_po: null } return res.json() }, enabled: !!invoice?.supplier_id, }) useEffect(() => { if (invoice) { setInvoiceNumber(invoice.invoice_number || '') setInvoiceDate(invoice.invoice_date || '') setTotal(invoice.total?.toString() || '') setNetTotal(invoice.net_total?.toString() || '') setCategory(invoice.category || 'food') setOrderNumber(invoice.order_number || '') setDocumentType(invoice.document_type || 'invoice') setSupplierId(invoice.supplier_id?.toString() || '') setInvoiceNotes(invoice.notes || '') } }, [invoice]) // Auto-reload when a PENDING invoice finishes processing useEffect(() => { if (!invoice || invoice.status !== 'PENDING') return const pollInterval = setInterval(async () => { try { const checkRes = await fetch(`/kitchen/api/invoices/${id}`, { headers: { Authorization: `Bearer ${token}` }, }) if (checkRes.ok) { const inv = await checkRes.json() if (inv.status !== 'PENDING') { clearInterval(pollInterval) window.location.reload() } } } catch { // Ignore polling errors } }, 2000) return () => clearInterval(pollInterval) }, [invoice?.status, id, token]) // Render all PDF pages to canvases for highlighting support useEffect(() => { const renderPdf = async () => { if (!invoice || !token) return const isPdf = invoice.image_path?.toLowerCase().endsWith('.pdf') if (!isPdf || !containerRef.current) return try { // Fetch the PDF const pdfUrl = `/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token)}` const response = await fetch(pdfUrl) const arrayBuffer = await response.arrayBuffer() // Load the PDF document const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise const numPages = pdf.numPages // Get container width for display scaling // Account for: imageSection padding (48px) + pdfScrollContainer padding (32px) + extra margin (32px) = 112px total const containerWidth = containerRef.current.clientWidth - 112 // Render at high fixed resolution for quality (matches upload max of 2000px) const targetRenderWidth = 1500 // High quality render width // Render all pages const pages: { width: number; height: number; displayWidth: number; displayHeight: number; canvas: HTMLCanvasElement }[] = [] for (let pageNum = 1; pageNum <= numPages; pageNum++) { const page = await pdf.getPage(pageNum) const viewport = page.getViewport({ scale: 1 }) // Calculate display size for container fit const displayScale = containerWidth / viewport.width const displayWidth = viewport.width * displayScale const displayHeight = viewport.height * displayScale // Render at high resolution (independent of display size) const renderScale = targetRenderWidth / viewport.width const renderViewport = page.getViewport({ scale: renderScale }) // Create canvas for this page at high resolution const canvas = document.createElement('canvas') canvas.width = renderViewport.width canvas.height = renderViewport.height const context = canvas.getContext('2d') if (context) { // If pdf_preview_show_annotations is false, hide PDF annotations const annotationMode = settings?.pdf_preview_show_annotations === false ? AnnotationMode.DISABLE : AnnotationMode.ENABLE await page.render({ canvasContext: context, viewport: renderViewport, canvas: canvas, annotationMode: annotationMode, }).promise } pages.push({ width: renderViewport.width, // High-res canvas dimensions height: renderViewport.height, displayWidth, // Display dimensions (half of canvas for 2x) displayHeight, canvas, }) } setPdfPages(pages) } catch (err) { console.error('Error rendering PDF:', err) } } renderPdf() }, [invoice, token, id, settings?.pdf_preview_show_annotations]) const updateMutation = useMutation({ mutationFn: async (data: Partial) => { const res = await fetch(`/kitchen/api/invoices/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(data), }) if (!res.ok) { const errorData = await res.json().catch(() => ({})) const errorMessage = errorData.detail?.message || errorData.detail || 'Failed to update' const error = new Error(errorMessage) as Error & { code?: string } error.code = errorData.detail?.error throw error } return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoice', id] }) queryClient.invalidateQueries({ queryKey: ['invoices'] }) queryClient.invalidateQueries({ queryKey: ['invoice-line-items', id] }) }, }) const deleteMutation = useMutation({ mutationFn: async () => { const res = await fetch(`/kitchen/api/invoices/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to delete') return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }) navigate('/invoices') }, }) const updateLineItemMutation = useMutation({ mutationFn: async ({ itemId, data }: { itemId: number; data: Partial }) => { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(data), }) if (!res.ok) throw new Error('Failed to update') return res.json() }, onSuccess: () => { refetchLineItems() queryClient.invalidateQueries({ queryKey: ['invoice', id] }) // Refresh stock_total setEditingLineItem(null) setLineItemEdits({}) setExpandedLineItem(null) // Close line item preview }, }) const createLineItemMutation = useMutation({ mutationFn: async (data: Partial) => { const res = await fetch(`/kitchen/api/invoices/${id}/line-items`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(data), }) if (!res.ok) throw new Error('Failed to create line item') return res.json() }, onSuccess: () => { refetchLineItems() queryClient.invalidateQueries({ queryKey: ['invoice', id] }) setEditingLineItem(null) setLineItemEdits({}) }, }) const deleteLineItemMutation = useMutation({ mutationFn: async (itemId: number) => { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, }, }) if (!res.ok) throw new Error('Failed to delete line item') return res.json() }, onSuccess: () => { refetchLineItems() queryClient.invalidateQueries({ queryKey: ['invoice', id] }) setEditingLineItem(null) }, }) const saveDefinitionMutation = useMutation({ mutationFn: async ({ itemId, portionDesc }: { itemId: number; portionDesc?: string }) => { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}/save-definition`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ portion_description: portionDesc || null }), }) if (!res.ok) { const err = await res.json() throw new Error(err.detail || 'Failed to save definition') } return res.json() }, }) const createSupplierMutation = useMutation({ mutationFn: async (name: string) => { const res = await fetch('/kitchen/api/suppliers/', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name }), }) if (!res.ok) throw new Error('Failed to create supplier') return res.json() }, onSuccess: (newSupplier: { id: number; name: string }) => { queryClient.invalidateQueries({ queryKey: ['suppliers'] }) setSupplierId(newSupplier.id.toString()) setShowCreateSupplierModal(false) setNewSupplierName('') }, }) const addAliasMutation = useMutation({ mutationFn: async ({ supplierId, alias, invoiceId }: { supplierId: number; alias: string; invoiceId?: number }) => { const res = await fetch(`/kitchen/api/suppliers/${supplierId}/aliases`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ alias, invoice_id: invoiceId }), }) if (!res.ok) throw new Error('Failed to add alias') return res.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['suppliers'] }) queryClient.invalidateQueries({ queryKey: ['invoice', id] }) }, }) 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) => { // Remove this suggestion from the map setAliasSuggestions(prev => { const next = { ...prev } delete next[variables.alias.toLowerCase()] return next }) setAddingAliasFor(null) // Refresh line items (descriptions will have been renamed) queryClient.invalidateQueries({ queryKey: ['invoice-line-items', id] }) }, }) // PO link/unlink handlers const handleLinkPo = async (poId: number) => { try { const res = await fetch(`/kitchen/api/purchase-orders/${poId}/link`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ invoice_id: parseInt(id!) }), }) if (!res.ok) throw new Error('Failed to link PO') refetchPoMatch() } catch { // silently fail, user can retry } } const handleUnlinkPo = async (poId: number) => { try { const res = await fetch(`/kitchen/api/purchase-orders/${poId}/unlink`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to unlink PO') refetchPoMatch() } catch { // silently fail } } const handleCreateSupplier = () => { if (newSupplierName.trim()) { createSupplierMutation.mutate(newSupplierName.trim()) } } const openCreateSupplierModal = () => { setNewSupplierName(invoice?.vendor_name || '') setShowCreateSupplierModal(true) } const handleSave = async (status: string = 'REVIEWED') => { await updateMutation.mutateAsync({ invoice_number: invoiceNumber || null, invoice_date: invoiceDate || null, total: total ? parseFloat(total) : null, net_total: netTotal ? parseFloat(netTotal) : null, supplier_id: supplierId ? parseInt(supplierId) : null, category, order_number: orderNumber || null, document_type: documentType, status, }) } // Auto-save when invoice details change (debounced) useEffect(() => { if (!invoice) return const timeoutId = setTimeout(() => { // Only auto-save if values have actually changed from the invoice const hasChanges = invoiceNumber !== (invoice.invoice_number || '') || invoiceDate !== (invoice.invoice_date || '') || total !== (invoice.total?.toString() || '') || netTotal !== (invoice.net_total?.toString() || '') || supplierId !== (invoice.supplier_id?.toString() || '') || category !== (invoice.category || 'food') || orderNumber !== (invoice.order_number || '') || documentType !== (invoice.document_type || 'invoice') if (hasChanges) { updateMutation.mutate({ invoice_number: invoiceNumber || null, invoice_date: invoiceDate || null, total: total ? parseFloat(total) : null, net_total: netTotal ? parseFloat(netTotal) : null, supplier_id: supplierId ? parseInt(supplierId) : null, category, order_number: orderNumber || null, document_type: documentType, }) } }, 1000) // 1 second debounce return () => clearTimeout(timeoutId) }, [invoiceNumber, invoiceDate, total, netTotal, supplierId, category, orderNumber, documentType]) const handleConfirm = async () => { // Warn (but allow) if there are open disputes if (invoice?.has_open_disputes) { if (!window.confirm( `This invoice has ${invoice.dispute_count} open dispute(s).\n\n` + `You can still confirm — the invoice will appear in reports while the dispute is tracked separately.\n\n` + `Continue?` )) { return } } // Warn if invoice date is in the future — likely a day/month swap if (invoiceDate && getDateWarning(invoiceDate, 'PENDING') === 'future') { const futureDate = new Date(invoiceDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); if (!window.confirm( `⚠ Invoice date is in the future: ${futureDate}\n\n` + `This is almost certainly a day/month format error (e.g. 6 April entered as 4 June).\n\n` + `Please double-check the date on the document before confirming.\n\n` + `Confirm anyway?` )) { return } } // Warn if any items were previously marked non-stock but aren't on this invoice const nonStockConflicts = (lineItems || []).filter(item => { const history = stockHistory?.[item.id.toString()] return history?.has_history && history.previously_non_stock && !item.is_non_stock }) if (nonStockConflicts.length > 0) { setNonStockConflictItems(nonStockConflicts.map(item => ({ description: item.description, amount: item.amount, }))) setShowNonStockConfirmModal(true) return } // Check for invoice date before confirming if (!invoiceDate) { alert( `Cannot confirm invoice without a date.\n\n` + `Please set the invoice date before confirming.\n\n` + `Invoices without dates cannot be found in date-filtered views.` ) return } await doConfirm() } const doConfirm = async () => { try { await handleSave('CONFIRMED') navigate('/invoices') } catch (error) { const message = error instanceof Error ? error.message : 'Failed to confirm invoice' alert(message) } } const handleDelete = () => { deleteMutation.mutate() } const handleMarkDextSent = async () => { if (!window.confirm('Mark this invoice as sent to Dext without actually sending?\n\nThis will also trigger Nextcloud archival if configured.')) { return } setAdminOperationInProgress(true) setAdminOperationResult(null) try { const res = await fetch(`/kitchen/api/invoices/${id}/mark-dext-sent`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to mark as sent') } const result = await res.json() setAdminOperationResult({ type: 'success', message: result.message + (result.archival_status ? `\n\n${result.archival_status}` : '') }) // Refresh invoice data queryClient.invalidateQueries({ queryKey: ['invoice', id] }) } catch (error) { setAdminOperationResult({ type: 'error', message: error instanceof Error ? error.message : 'Operation failed' }) } finally { setAdminOperationInProgress(false) } } const handleReprocessOCR = async () => { if (!window.confirm('Reprocess existing OCR data?\n\nThis will:\n- Re-identify supplier\n- Re-detect document type\n- Re-create line items with product definitions\n- Re-run duplicate detection\n\nExisting line items will be replaced.')) { return } setAdminOperationInProgress(true) setAdminOperationResult(null) try { const res = await fetch(`/kitchen/api/invoices/${id}/reprocess`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to reprocess') } const result = await res.json() setAdminOperationResult({ type: 'success', message: `${result.message}\n\nSupplier ID: ${result.supplier_id || 'None'}\nDocument Type: ${result.document_type}\nLine Items: ${result.line_items_count}\nDuplicate Status: ${result.duplicate_status || 'None'}` }) // Full page reload to ensure all data is fresh window.location.reload() } catch (error) { setAdminOperationResult({ type: 'error', message: error instanceof Error ? error.message : 'Operation failed' }) } finally { setAdminOperationInProgress(false) } } const handleFetchParsedDates = async () => { setParsedDatesLoading(true) setParsedDates([]) setShowDatePickerModal(true) try { const res = await fetch(`/kitchen/api/invoices/${id}/parse-dates`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { throw new Error('Failed to parse dates') } const data = await res.json() setParsedDates(data.found_dates || []) } catch (error) { console.error('Failed to fetch parsed dates:', error) } finally { setParsedDatesLoading(false) } } const handleSelectParsedDate = (dateStr: string) => { setInvoiceDate(dateStr) setShowDatePickerModal(false) } const handleFetchInvoiceNumberCandidates = async () => { setInvoiceNumberLoading(true) setInvoiceNumberCandidates([]) setInvoiceNumberExamples([]) setShowInvoiceNumberModal(true) try { const res = await fetch(`/kitchen/api/invoices/${id}/parse-invoice-number`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to search for invoice number') const data = await res.json() setInvoiceNumberCandidates(data.candidates || []) setInvoiceNumberExamples(data.supplier_examples || []) } catch (error) { console.error('Failed to fetch invoice number candidates:', error) } finally { setInvoiceNumberLoading(false) } } const handleSelectInvoiceNumber = (value: string) => { setInvoiceNumber(value) setShowInvoiceNumberModal(false) } const handleResendToAzure = async () => { if (!window.confirm('Re-send invoice to Azure for OCR extraction?\n\nThis will:\n- Fully re-extract data from Azure\n- Update all invoice fields\n- Re-create line items with product definitions\n- Re-run duplicate detection\n\nExisting data will be replaced.')) { return } setAdminOperationInProgress(true) setAdminOperationResult(null) try { const res = await fetch(`/kitchen/api/invoices/${id}/resend-to-azure`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to resend to Azure') } const result = await res.json() setAdminOperationResult({ type: 'success', message: `${result.message}\n\nThe page will reload automatically when processing completes.` }) // Poll for completion const pollInterval = setInterval(async () => { try { const checkRes = await fetch(`/kitchen/api/invoices/${id}`, { headers: { Authorization: `Bearer ${token}` }, }) if (checkRes.ok) { const inv = await checkRes.json() if (inv.status !== 'PENDING') { clearInterval(pollInterval) // Full page reload to ensure all data is fresh window.location.reload() } } } catch (e) { // Ignore polling errors } }, 2000) // Stop polling after 2 minutes setTimeout(() => clearInterval(pollInterval), 120000) } catch (error) { setAdminOperationResult({ type: 'error', message: error instanceof Error ? error.message : 'Operation failed' }) } finally { setAdminOperationInProgress(false) } } const handleRegenerateHighlights = async () => { setAdminOperationInProgress(true) setAdminOperationResult(null) try { const res = await fetch(`/kitchen/api/invoices/${id}/regenerate-highlights`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const error = await res.json() throw new Error(error.detail || 'Failed to regenerate highlights') } const result = await res.json() const nonStockCount = result.non_stock_count || 0 const hasNotes = result.has_notes let message = '' if (nonStockCount > 0) { message = `PDF highlights regenerated successfully.\n\nHighlighted ${nonStockCount} non-stock item(s).` } else { message = 'PDF highlights cleared (no non-stock items marked).' } if (hasNotes) { message += '\nNotes overlay added.' } setAdminOperationResult({ type: 'success', message }) // Refresh invoice to update PDF preview queryClient.invalidateQueries({ queryKey: ['invoice', id] }) } catch (error) { setAdminOperationResult({ type: 'error', message: error instanceof Error ? error.message : 'Operation failed' }) } finally { setAdminOperationInProgress(false) } } const startEditLineItem = (item: LineItem) => { setEditingLineItem(item.id) setLineItemEdits({ product_code: item.product_code, description: item.description, unit: item.unit, quantity: item.quantity, unit_price: item.unit_price, tax_rate: item.tax_rate, amount: item.amount, is_non_stock: item.is_non_stock, }) // Open line item preview setExpandedLineItem(item.id) setHighlightedField(null) resetZoom() } const saveLineItemEdit = (itemId: number) => { if (itemId === -1) { // This is a new line item - use POST createLineItemMutation.mutate(lineItemEdits) } else { // Existing line item - use PATCH updateLineItemMutation.mutate({ itemId, data: lineItemEdits }) } // Clear edit state immediately after save setEditingLineItem(null) setLineItemEdits({}) setExpandedLineItem(null) } const cancelEditLineItem = () => { setEditingLineItem(null) setLineItemEdits({}) setExpandedLineItem(null) } const handleAddLineItem = () => { // Create a temporary new line item with placeholder ID const newItemId = -1 // Negative ID indicates unsaved // Set editing state to new item setEditingLineItem(newItemId) setLineItemEdits({ product_code: '', description: '', unit: '', quantity: null, unit_price: null, tax_rate: '', amount: null, is_non_stock: false, line_number: (filteredAndSortedLineItems.length || 0) + 1 }) } const handleDeleteLineItem = (itemId: number) => { if (window.confirm('Are you sure you want to delete this line item? This action cannot be undone.')) { deleteLineItemMutation.mutate(itemId) } } // Helper to ensure item is being edited when user interacts with fields in bulk mode const ensureEditing = (item: LineItem) => { if (editingLineItem !== item.id) { startEditLineItem(item) } } // Build dynamic list of supplier words from loaded suppliers const getSupplierWords = (): string[] => { if (!suppliers) return [] const words: string[] = [] for (const supplier of suppliers) { // Add each word from supplier name if (supplier.name) { words.push(...supplier.name.toLowerCase().split(/\s+/)) } // Add each alias and its words if (supplier.aliases) { for (const alias of supplier.aliases) { if (alias) { words.push(...alias.toLowerCase().split(/\s+/)) } } } } // Remove duplicates and filter short words return [...new Set(words)].filter(w => w.length > 1) } // Extract meaningful keywords from description for search const extractKeywords = (description: string): string => { if (!description) return '' let text = description // Remove pack sizes (12x1L, 120x15g) text = text.replace(/\b\d+\s*x\s*\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl)?\b/gi, '') // Remove quantity patterns text = text.replace(/\b(qty|quantity)\s*:?\s*\d+\b/gi, '') text = text.replace(/\bcase\s*(of\s*)?\d+\b/gi, '') // Remove product codes like (L-AG) text = text.replace(/\([A-Z]{1,3}-?[A-Z0-9]{1,5}\)/gi, '') text = text.replace(/\[[A-Z0-9-]+\]/gi, '') // Remove generic packaging terms text = text.replace(/\b(case|qty|un|pack|box|each|per|unit|pkt|bag|bottle|tin|can|carton|tray|portion|portions)\b/gi, '') // Remove standalone numbers and weights text = text.replace(/\b\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl|lb)?\b/gi, '') // Clean up special characters text = text.replace(/[^\w\s]/g, ' ') // Remove common English stop words text = text.replace(/\b(the|a|an|in|on|at|by|for|with|to|of|and|or|is|it|as|be|are|was|been|being|have|has|had|do|does|did|will|would|could|should|may|might|this|that|these|those|from|into|through|during|before|after|above|below|between|under|over)\b/gi, '') // Remove supplier names dynamically from loaded suppliers const supplierWords = getSupplierWords() if (supplierWords.length > 0) { const supplierPattern = new RegExp(`\\b(${supplierWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi') text = text.replace(supplierPattern, '') } return text.split(/\s+/).filter(w => w.length > 1).join(' ').trim() } const openSearchModal = (item: LineItem) => { setSearchingLineItem(item) const keywords = extractKeywords(item.description || '') setSearchQuery(keywords) setSearchResults(null) setShowSearchModal(true) // Auto-search if we have keywords if (keywords) { performSearch(keywords) } } const openPriceHistoryModal = (item: LineItem) => { if (!invoice?.supplier_id) return setHistoryModal({ isOpen: true, productCode: item.product_code, description: item.description, unit: item.unit, supplierId: invoice.supplier_id, supplierName: invoice.supplier_name || 'Unknown', currentPrice: item.unit_price || undefined, sourceInvoiceId: invoice.id, sourceLineItemId: item.id, }) } const performSearch = async (query: string) => { if (!query || query.length < 2) return setSearchLoading(true) try { const res = await fetch('/kitchen/api/invoices/line-items/search', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query, exclude_invoice_id: id ? parseInt(id) : null }) }) if (res.ok) { setSearchResults(await res.json()) } } finally { setSearchLoading(false) } } const handleBulkStockUpdate = async (markAsStock: boolean) => { if (!lineItems) return try { // Update all line items const promises = lineItems.map(item => fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ is_non_stock: !markAsStock }), }) ) await Promise.all(promises) await refetchLineItems() queryClient.invalidateQueries({ queryKey: ['invoice', id] }) setShowBulkStockModal(false) } catch (error) { console.error('Failed to bulk update stock status:', error) } } // LLM FEATURE — AI Match handler for ingredient matching — see LLM-MANIFEST.md for removal instructions const handleAiMatch = async (description: string) => { setAiMatchLoading(true) setAiMatchResults([]) try { const res = await fetch(`/kitchen/api/ingredients/ai-match?description=${encodeURIComponent(description)}`, { headers: { Authorization: `Bearer ${token}` }, }) if (res.ok) { const data = await res.json() if (data.ranked?.length > 0) { setAiMatchResults(data.ranked) } } } catch { /* ignore */ } setAiMatchLoading(false) } // LLM FEATURE — AI Assist handler — see LLM-MANIFEST.md for removal instructions const handleAiAssist = async () => { setAiAssistLoading(true) setAiAssistError(null) setAiAssistSuggestions(null) setAiReconciliationMatches([]) setAiDismissedCorrections(new Set()) try { const res = await fetch(`/kitchen/api/invoices/${id}/ai-assist`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { let detail = 'AI analysis failed' try { const err = await res.json(); detail = err.detail || detail } catch { /* non-JSON response */ } throw new Error(detail) } const data = await res.json() if (data.suggestions) setAiAssistSuggestions(data.suggestions) if (data.reconciliation_matches) setAiReconciliationMatches(data.reconciliation_matches) if (data.error) setAiAssistError(data.error) } catch (error) { setAiAssistError(error instanceof Error ? error.message : 'AI analysis failed') } finally { setAiAssistLoading(false) } } const handleApplyAiCorrection = async (idx: number, field: string, value: number | string) => { if (!lineItems) return const item = lineItems[idx] if (!item) return try { await updateLineItemMutation.mutateAsync({ itemId: item.id, data: { [field]: value } as Partial }) setAiDismissedCorrections(prev => new Set([...prev, `${idx}-${field}`])) } catch (error) { console.error('Failed to apply AI correction:', error) } } const handleApplyAiSupplier = async (supplierId: number) => { try { await updateMutation.mutateAsync({ supplier_id: supplierId } as Partial) } catch (error) { console.error('Failed to apply AI supplier match:', error) } } // END LLM FEATURE const toggleCostBreakdown = async (item: LineItem) => { // Open ingredient mapping modal setIngredientModalItem(item) setAiPackSizeLoading(false) setAiPackSizeSource(null) setCostBreakdownEdits({ pack_quantity: item.pack_quantity, unit_size: item.unit_size, unit_size_type: item.unit_size_type, portions_per_unit: item.portions_per_unit, unit_price: item.unit_price, }) setPortionDescription('') setSaveAsDefault(false) setCurrentDefinition(null) setShowLegacyPortioning(false) setShowCreateIngredient(false) setIngredientSearch('') setIngredientSuggestions([]) setSelectedIngredientId(item.ingredient_id || null) setSelectedIngredientName(item.ingredient_name || '') setSelectedIngredientUnit(item.ingredient_unit || '') // Show conversion display immediately if pack data exists // Works with or without ingredient — falls back to unit_size_type as target unit if (item.unit_size && item.unit_size_type) { updateConversionDisplay(item.ingredient_unit || undefined, { pack_quantity: item.pack_quantity, unit_size: item.unit_size, unit_size_type: item.unit_size_type, unit_price: item.unit_price, }) } // Fetch saved definition setDefinitionLoading(true) try { const res = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/definition`, { headers: { Authorization: `Bearer ${token}` }, }) if (res.ok) { const def = await res.json() setCurrentDefinition(def) setPortionDescription(def?.portion_description || '') } } catch { /* ignore */ } setDefinitionLoading(false) // Auto-suggest ingredient match from description if (item.description && !item.ingredient_id) { searchIngredients(item.description) } // LLM FEATURE — see LLM-MANIFEST.md for removal instructions // Auto-deduce pack size if not already set (regex → unit field → AI, all server-side) if (!item.pack_quantity && !item.unit_size) { setAiPackSizeLoading(true) try { const packRes = await fetch(`/kitchen/api/invoices/${id}/line-items/${item.id}/ai-pack-size`, { headers: { Authorization: `Bearer ${token}` }, }) if (packRes.ok) { const packData = await packRes.json() if (packData.pack_quantity && packData.unit_size) { setAiPackSizeSource(packData.source) setCostBreakdownEdits(prev => ({ ...prev, pack_quantity: packData.pack_quantity, unit_size: packData.unit_size, unit_size_type: packData.unit_size_type, })) updateConversionDisplay(item.ingredient_unit || undefined, { pack_quantity: packData.pack_quantity, unit_size: packData.unit_size, unit_size_type: packData.unit_size_type, unit_price: item.unit_price, }) } } } catch { /* non-blocking */ } setAiPackSizeLoading(false) } // END LLM FEATURE } 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) { const data = await res.json() setIngredientSuggestions(data) } } catch { /* ignore */ } setIngredientSearchLoading(false) } const selectIngredient = (ing: IngredientSuggestion) => { setSelectedIngredientId(ing.id) setSelectedIngredientName(ing.name) setSelectedIngredientUnit(ing.standard_unit) setIngredientSearch('') setIngredientSuggestions([]) // Default the unit type to ingredient's standard unit if not already set if (!costBreakdownEdits.unit_size_type) { setCostBreakdownEdits(prev => ({ ...prev, unit_size_type: ing.standard_unit })) } updateConversionDisplay(ing.standard_unit) } const updateConversionDisplay = (stdUnit?: string, overrides?: Partial) => { const edits = overrides ? { ...costBreakdownEdits, ...overrides } : costBreakdownEdits const unit = stdUnit || selectedIngredientUnit || edits.unit_size_type const pq = edits.pack_quantity || 1 const us = edits.unit_size const ust = edits.unit_size_type const up = edits.unit_price if (!us || !ust || !unit) { setIngredientConversionDisplay('') return } // Unit conversion factors: source unit → ingredient standard unit const conversions: Record> = { 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) { setIngredientConversionDisplay(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} = ` : '' // Build display string let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${unit}` if (pricePerStd) { display += ` → £${pricePerStd.toFixed(4)} per ${unit}` // For g/ml also show per kg/ltr if (unit === 'g' && pricePerStd) { display += ` (£${(pricePerStd * 1000).toFixed(2)}/kg)` } else if (unit === 'ml' && pricePerStd) { display += ` (£${(pricePerStd * 1000).toFixed(2)}/ltr)` } } setIngredientConversionDisplay(display) } const handleIngredientCreated = (_result: IngredientModalResult) => { // IngredientModal already created the source mapping, so close everything refetchLineItems() closeIngredientModal() } const closeIngredientModal = () => { setIngredientModalItem(null) setCostBreakdownEdits({}) setPortionDescription('') setSaveAsDefault(false) setCurrentDefinition(null) setSelectedIngredientId(null) setSelectedIngredientName('') setSelectedIngredientUnit('') setShowCreateIngredient(false) setIngredientSearch('') setIngredientSuggestions([]) setIngredientConversionDisplay('') } const saveCostBreakdown = async (itemId: number) => { // Update the line item pack fields await updateLineItemMutation.mutateAsync({ itemId, data: costBreakdownEdits }) // If an ingredient is selected, set ingredient_id and create/update ingredient_source if (selectedIngredientId) { try { // Set ingredient_id on line item await fetch(`/kitchen/api/invoices/${id}/line-items/${itemId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ingredient_id: selectedIngredientId }), }) // Create ingredient source mapping (links supplier product → ingredient) if (invoice?.supplier_id) { const sourceData: Record = { supplier_id: invoice.supplier_id, pack_quantity: costBreakdownEdits.pack_quantity || 1, unit_size: costBreakdownEdits.unit_size || null, unit_size_type: costBreakdownEdits.unit_size_type || selectedIngredientUnit || null, } // Use product_code if available, otherwise description pattern const li = ingredientModalItem if (li?.product_code) { sourceData.product_code = li.product_code } else if (li?.description) { sourceData.description_pattern = li.description.substring(0, 100).toLowerCase().trim() } // Include price and invoice ref for price tracking if (costBreakdownEdits.unit_price) { sourceData.latest_unit_price = costBreakdownEdits.unit_price } if (id) { sourceData.invoice_id = parseInt(id as string) } const srcRes = await fetch(`/kitchen/api/ingredients/${selectedIngredientId}/sources`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(sourceData), }) if (!srcRes.ok) { const errBody = await srcRes.json().catch(() => ({})) console.warn('Source creation failed:', srcRes.status, errBody) } } } catch (err) { console.error('Failed to set ingredient mapping:', err) } } // Save as default definition if checked if (saveAsDefault) { try { await saveDefinitionMutation.mutateAsync({ itemId, portionDesc: portionDescription }) } catch (err) { console.error('Failed to save definition:', err) } } closeIngredientModal() refetchLineItems() } // Sort and filter line items - must be before early returns (Rules of Hooks) const filteredAndSortedLineItems = useMemo(() => { if (!lineItems) return [] // Use line_number (original OCR index) so bounding boxes stay correct after deletions let filtered = lineItems.map((item) => ({ ...item, _originalIndex: item.line_number })) // Filter by search text (product code or description) if (lineItemSearchText) { const searchLower = lineItemSearchText.toLowerCase() filtered = filtered.filter(item => { const code = (item.product_code || '').toLowerCase() const desc = (item.description || '').toLowerCase() return code.includes(searchLower) || desc.includes(searchLower) }) } // Filter by price change status if (lineItemPriceFilter) { filtered = filtered.filter(item => { if (lineItemPriceFilter === 'consistent') return item.price_change_status === 'consistent' if (lineItemPriceFilter === 'amber') return item.price_change_status === 'amber' if (lineItemPriceFilter === 'red') return item.price_change_status === 'red' if (lineItemPriceFilter === 'no_history') return item.price_change_status === 'no_history' return true }) } // Filter by portions definition if (lineItemPortionsFilter) { filtered = filtered.filter(item => { const hasPortions = item.portions_per_unit != null && item.portions_per_unit > 0 if (lineItemPortionsFilter === 'yes') return hasPortions if (lineItemPortionsFilter === 'no') return !hasPortions return true }) } // Filter by missing key data if (lineItemMissingDataFilter === 'missing') { filtered = filtered.filter(item => { const missingQty = item.quantity == null || item.quantity === 0 const missingPrice = item.unit_price == null || item.unit_price === 0 const missingAmount = item.amount == null || item.amount === 0 return missingQty || missingPrice || missingAmount }) } // Sort if (lineItemSortColumn) { filtered.sort((a, b) => { let aVal: any = null let bVal: any = null switch (lineItemSortColumn) { case 'code': aVal = a.product_code || '' bVal = b.product_code || '' break case 'description': aVal = a.description || '' bVal = b.description || '' break case 'unit': aVal = a.unit || '' bVal = b.unit || '' break case 'quantity': aVal = a.quantity || 0 bVal = b.quantity || 0 break case 'unit_price': aVal = a.unit_price || 0 bVal = b.unit_price || 0 break case 'price_change': aVal = a.price_change_percent || 0 bVal = b.price_change_percent || 0 break case 'amount': aVal = a.amount || 0 bVal = b.amount || 0 break } if (typeof aVal === 'string') { return lineItemSortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal) } else { return lineItemSortDirection === 'asc' ? aVal - bVal : bVal - aVal } }) } return filtered }, [lineItems, lineItemSortColumn, lineItemSortDirection, lineItemPriceFilter, lineItemSearchText, lineItemPortionsFilter, lineItemMissingDataFilter]) // Track visible page in line items table for sticky indicator useEffect(() => { const container = lineItemsTableRef.current if (!container) return const handleScroll = () => { // Find all rows with data-page attribute const rows = container.querySelectorAll('tr[data-page]') if (rows.length === 0) return const containerTop = container.getBoundingClientRect().top + 50 // Account for sticky header // Find the first visible row (closest to top of container) for (const row of rows) { const rect = row.getBoundingClientRect() if (rect.top >= containerTop - 10) { const pageNum = parseInt(row.getAttribute('data-page') || '1', 10) setCurrentVisiblePage(pageNum) return } } // If no row found above container top, use the last row's page const lastRow = rows[rows.length - 1] const pageNum = parseInt(lastRow.getAttribute('data-page') || '1', 10) setCurrentVisiblePage(pageNum) } container.addEventListener('scroll', handleScroll) // Initial check handleScroll() return () => container.removeEventListener('scroll', handleScroll) }, [filteredAndSortedLineItems]) // Calculate line item statistics for checks section const lineItemStats = useMemo(() => { if (!lineItems) return { total: 0, withPortions: 0, withoutPortions: 0, missingData: 0, nonStock: 0, priceCalcErrors: 0, totalsMatch: true, totalDifference: 0, lineItemsTotal: 0, stockItemsTotal: 0, nonStockItemsTotal: 0, stockItemsGross: 0 } const withPortions = lineItems.filter(item => item.portions_per_unit != null && item.portions_per_unit > 0 ).length const missingData = lineItems.filter(item => { const missingQty = item.quantity == null || item.quantity === 0 const missingPrice = item.unit_price == null || item.unit_price === 0 const missingAmount = item.amount == null || item.amount === 0 return missingQty || missingPrice || missingAmount }).length const nonStock = lineItems.filter(item => item.is_non_stock).length // Count items with price calculation errors // Valid cases: qty only (free item), or all three values with correct calculation const priceCalcErrors = lineItems.filter(item => { const hasQty = item.quantity != null && item.quantity !== 0 const hasPrice = item.unit_price != null && item.unit_price !== 0 const hasTotal = item.amount != null && item.amount !== 0 // qty * price should equal amount (works for both positive and negative/credit lines) const expectedTotal = (item.quantity || 0) * (item.unit_price || 0) const actualTotal = item.amount || 0 const calculationMismatch = Math.abs(expectedTotal - actualTotal) > 0.02 return ( (hasPrice && (!hasQty || !hasTotal)) || // price requires both qty and total (hasTotal && (!hasQty || !hasPrice)) || // total requires both qty and price (hasQty && hasPrice && hasTotal && calculationMismatch) // calculation mismatch ) }).length // Calculate totals (line items are NET values) const lineItemsTotal = lineItems.reduce((sum, item) => sum + (item.amount || 0), 0) const stockItemsTotal = lineItems .filter(item => !item.is_non_stock) .reduce((sum, item) => sum + (item.amount || 0), 0) const nonStockItemsTotal = lineItems .filter(item => item.is_non_stock) .reduce((sum, item) => sum + (item.amount || 0), 0) // Calculate VAT ratio from invoice totals const invoiceGross = parseFloat(total) || 0 const invoiceNet = parseFloat(netTotal) || invoiceGross const vatRatio = invoiceNet > 0 ? invoiceGross / invoiceNet : 1 // Line item amounts are NET values // Calculate GROSS for stock items by multiplying by VAT ratio const stockItemsNet = stockItemsTotal const stockItemsGross = stockItemsTotal * vatRatio // Check if line items total matches invoice total // Line items are NET, so compare against invoice NET (or gross if no net available) const compareTotal = invoiceNet const difference = Math.abs(compareTotal - lineItemsTotal) const totalsMatch = difference <= TOLERANCE return { total: lineItems.length, withPortions, withoutPortions: lineItems.length - withPortions, missingData, nonStock, priceCalcErrors, totalsMatch, totalDifference: difference, lineItemsTotal, stockItemsTotal, stockItemsNet, nonStockItemsTotal, stockItemsGross } }, [lineItems, total, netTotal]) // Check if line items have validation errors (for disabling Confirm/Dext buttons) const hasLineItemErrors = useMemo(() => { // No line items = error if (!lineItems || lineItems.length === 0) return true // Totals don't match = error if (!lineItemStats.totalsMatch) return true // Price calculation errors = error if (lineItemStats.priceCalcErrors > 0) return true return false }, [lineItems, lineItemStats.totalsMatch, lineItemStats.priceCalcErrors]) // Calculate filter option counts for disabling const filterOptionCounts = useMemo(() => { if (!lineItems) return { consistent: 0, amber: 0, red: 0, no_history: 0, withPortions: 0, withoutPortions: 0, missingData: 0 } return { consistent: lineItems.filter(item => item.price_change_status === 'consistent').length, amber: lineItems.filter(item => item.price_change_status === 'amber').length, red: lineItems.filter(item => item.price_change_status === 'red').length, no_history: lineItems.filter(item => item.price_change_status === 'no_history').length, withPortions: lineItems.filter(item => item.portions_per_unit != null && item.portions_per_unit > 0).length, withoutPortions: lineItems.filter(item => !(item.portions_per_unit != null && item.portions_per_unit > 0)).length, missingData: lineItems.filter(item => { const missingQty = item.quantity == null || item.quantity === 0 const missingPrice = item.unit_price == null || item.unit_price === 0 const missingAmount = item.amount == null || item.amount === 0 return missingQty || missingPrice || missingAmount }).length } }, [lineItems]) const handleLineItemSort = (column: string) => { if (lineItemSortColumn === column) { setLineItemSortDirection(lineItemSortDirection === 'asc' ? 'desc' : 'asc') } else { setLineItemSortColumn(column) setLineItemSortDirection('asc') } } if (isLoading) { return
Loading invoice...
} if (!invoice) { return
Invoice not found
} const confidence = invoice.ocr_confidence ? (Number(invoice.ocr_confidence) * 100).toFixed(0) : null // Check if the file is a PDF const isPDF = invoice?.image_path?.toLowerCase().endsWith('.pdf') return (
{/* Error banner for Azure OCR failures */} {invoice?.ocr_raw_text && invoice.ocr_raw_text.startsWith('Error:') && (
⚠️
OCR Processing Error

{invoice.ocr_raw_text.replace('Error: ', '')}

{invoice.ocr_raw_text.includes('quota exceeded') && (

Next steps: Check your Azure subscription budget limits in the Azure portal. Once resolved, use the "Reprocess" button below to retry OCR extraction.

)} {invoice.ocr_raw_text.includes('authentication failed') && (

Next steps: Verify your Azure API credentials in Settings → Azure Configuration.

)}
)} {/* Processing banner for PENDING invoices */} {invoice.status === 'PENDING' && (
Processing Invoice

Azure OCR extraction is in progress. The page will reload automatically when complete.

)} {/* Top row: Image and Form side by side */}

Invoice {isPDF ? 'Document' : 'Image'}

{imageUrl ? ( isPDF ? (
{pdfPages.length > 0 ? ( pdfPages.map((page, pageIndex) => { const pageNum = pageIndex + 1 const fieldBbox = highlightedField ? getFieldBoundingBox(highlightedField) : null const lineItemBbox = expandedLineItem !== null && lineItems ? (() => { const item = lineItems.find(item => item.id === expandedLineItem) return item ? getLineItemBoundingBox(item.line_number) : null })() : null return (
{/* Wrapper for image + highlights with actual size scaling for scrollable zoom */}
{`Page {/* Highlight for header fields on this page */} {fieldBbox && fieldBbox.pageNumber === pageNum && (() => { // Add padding (0.5% ≈ 5px at typical sizes) and scale border inversely to zoom const padding = 0.5 const borderWidth = Math.max(1, 2 / zoomLevel) return (
{ setHighlightedField(null); resetZoom(); }} /> ) })()} {/* Highlight for line items on this page */} {lineItemBbox && lineItemBbox.pageNumber === pageNum && (() => { const padding = 0.5 const borderWidth = Math.max(1, 2 / zoomLevel) return (
{ setExpandedLineItem(null); resetZoom(); }} /> ) })()}
{pdfPages.length > 1 && (
Page {pageNum} of {pdfPages.length}
)}
) }) ) : (
Rendering PDF...
)}
) : ( <>
{Math.round(imageZoom * 100)}% {imageZoom > 1 && ( )}
1 ? 'grab' : 'default' }} ref={imageContainerRef} onWheel={(e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault() const delta = e.deltaY > 0 ? -0.1 : 0.1 setImageZoom(Math.max(1, Math.min(5, imageZoom + delta))) } }} >
Invoice {highlightedField && getFieldBoundingBox(highlightedField) && (() => { const bbox = getFieldBoundingBox(highlightedField)! const padding = 0.5 return (
setHighlightedField(null)} /> ) })()}
) ) : (
Loading {isPDF ? 'document' : 'image'}...
)} {isPDF && zoomLevel > 1 && ( )} {isPDF && imageUrl && ( Open PDF in new tab )} {confidence && (
OCR Confidence: {confidence}%
)}
{/* Duplicate Warning Banner */} {invoice.duplicate_status && (
setShowDuplicateModal(true)} > {invoice.duplicate_status === 'firm_duplicate' ? '⚠️ DUPLICATE: This invoice matches an existing record. Click to compare.' : '⚠️ Possible duplicate detected. Click to compare.'}
)} {/* PO Linked Indicator */} {poMatchData?.linked_po && (
Linked to PO-{poMatchData.linked_po.po_id} {' '}({poMatchData.linked_po.total_amount != null ? `£${poMatchData.linked_po.total_amount.toFixed(2)}` : 'N/A'}, {poMatchData.linked_po.order_date ? new Date(poMatchData.linked_po.order_date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''})
)} {/* PO Matching Suggestions */} {!poMatchData?.linked_po && poMatchData?.matches && poMatchData.matches.length > 0 && (
This supplier has {poMatchData.matches.length} pending Purchase Order{poMatchData.matches.length > 1 ? 's' : ''}
{poMatchData.matches.map((m, idx) => { const isSuggested = idx === 0 && m.confidence >= 0.8 const isPotential = !isSuggested && m.confidence >= 0.8 return (
PO-{m.po_id}: {m.total_amount != null ? `£${m.total_amount.toFixed(2)}` : 'N/A'} ({m.order_date ? new Date(m.order_date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : 'No date'}) {isSuggested && ( Suggested match )} {isPotential && ( Potential match )}
) })}
)} {/* Header: Title | Source Badge | Status Badge */}

Invoice Details

{invoice.source && invoice.source !== 'upload' && ( )} {invoice.status.toUpperCase()} {invoice.document_type === 'delivery_note' && ' (DN)'}
{/* Supplier (full width) */}
Supplier
{getFieldBoundingBox('VendorName') && ( )}
{invoice?.supplier_match_type === 'fuzzy' && supplierId && invoice.vendor_name && (
Fuzzy match from "{invoice.vendor_name}" - please verify
)} {invoice?.vendor_name && !supplierId && (
Extracted: {invoice.vendor_name}
)}
{/* Date | Type */}
{/* Number | PO */}
{/* Net | Gross */}
{/* Notes */}