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

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

5944 lines
272 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

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 }) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
style={{ width: '18px', height: '18px', ...style }}
>
<path d="M12 3c-1.27 0-2.4.8-2.82 2H3v2h1.95L2 14c-.47 2 1 4 4 4s4.5-2 4-4L7.05 7H9.18c.35.99 1.17 1.74 2.18 1.94V19H7v2h10v-2h-4.18V8.94c1.01-.2 1.83-.95 2.18-1.94h2.13L14 14c-.47 2 1 4 4 4s4.5-2 4-4l-2.95-7H21V5h-6.18C14.4 3.8 13.27 3 12 3zm0 2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM6 10.45L7.5 14h-3L6 10.45zm12 0L19.5 14h-3L18 10.45z" />
</svg>
)
// Source badge component - shows where invoice came from
const sourceConfig: Record<string, { label: string; color: string; icon: string }> = {
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 (
<span
style={{
background: color,
color: 'white',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
fontWeight: '600',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
}}
title={sourceReference || undefined}
>
{icon} {label}
</span>
)
}
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<DateWarning, React.CSSProperties> = {
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 (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: '#fef2f2',
borderRadius: '6px',
border: '1px solid #fca5a5'
}}>
<div style={{ color: '#dc2626', fontWeight: '600', marginBottom: '0.5rem' }}>
No Line Items
</div>
<div style={{ color: '#991b1b', fontSize: '0.9rem' }}>
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.
</div>
<div style={{ marginTop: '0.5rem', color: '#991b1b', fontSize: '0.9rem' }}>
<strong>Invoice Total:</strong> £{invoiceTotal.toFixed(2)}
</div>
</div>
);
}
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 (
<div style={{
marginTop: '1rem',
padding: '0.75rem',
background: isValid ? '#d4edda' : '#fff3cd',
borderRadius: '6px',
border: `1px solid ${isValid ? '#c3e6cb' : '#ffeeba'}`,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: '500' }}>
Line Items Total: <strong>£{lineItemsTotal.toFixed(2)}</strong>
</span>
<span style={{ fontWeight: '500' }}>
Invoice Net: <strong>£{(netTotal ?? invoiceTotal).toFixed(2)}</strong>
{netTotal && <span style={{ fontSize: '0.85rem', color: '#666' }}> (Gross: £{invoiceTotal.toFixed(2)})</span>}
</span>
</div>
{hasNonStock && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: 'rgba(255,255,255,0.5)', borderRadius: '4px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
<span><strong>Stock Items:</strong> £{stockItemsTotal.toFixed(2)}</span>
<span style={{ color: '#856404' }}><strong>Non-Stock:</strong> £{nonStockItemsTotal.toFixed(2)}</span>
</div>
<div style={{ marginTop: '0.25rem', fontSize: '0.8rem', color: '#155724' }}>
GP will be calculated using stock items only
</div>
</div>
)}
{exactMatch ? (
<div style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#155724' }}>
Totals match
</div>
) : (
<div style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: isValid ? '#155724' : '#856404' }}>
{isValid ? '✓ ' : '⚠ '}Difference: £{difference.toFixed(2)}
</div>
)}
</div>
);
}
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<string>('')
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showCreateSupplierModal, setShowCreateSupplierModal] = useState(false)
const [showDisputeModal, setShowDisputeModal] = useState(false)
const [viewingDisputeId, setViewingDisputeId] = useState<number | null>(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<LineItem | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResponse | null>(null)
const [searchLoading, setSearchLoading] = useState(false)
const [newSupplierName, setNewSupplierName] = useState('')
const [editingLineItem, setEditingLineItem] = useState<number | null>(null)
const [lineItemEdits, setLineItemEdits] = useState<Partial<LineItem>>({})
const [bulkEditMode, setBulkEditMode] = useState(false)
const [highlightedField, setHighlightedField] = useState<string | null>(null)
const [expandedLineItem, setExpandedLineItem] = useState<number | null>(null)
const [costBreakdownEdits, setCostBreakdownEdits] = useState<Partial<LineItem>>({})
const [descSwapItem, setDescSwapItem] = useState<LineItem | null>(null) // For description swap modal
const [portionDescription, setPortionDescription] = useState<string>('')
const [saveAsDefault, setSaveAsDefault] = useState(false)
const [currentDefinition, setCurrentDefinition] = useState<ProductDefinition | null>(null)
const [definitionLoading, setDefinitionLoading] = useState(false)
const [pdfPages, setPdfPages] = useState<{ width: number; height: number; displayWidth: number; displayHeight: number; canvas: HTMLCanvasElement }[]>([])
const [zoomLevel, setZoomLevel] = useState<number>(1)
const [zoomPageNum, setZoomPageNum] = useState<number>(0) // Which page the zoom applies to
const [imageZoom, setImageZoom] = useState<number>(1) // Zoom level for non-PDF images
const pdfContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const highlightRef = useRef<HTMLDivElement>(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<Array<{ date: string; original: string; context: string; bbox?: { x: number; y: number; width: number; height: number; pageNumber: number } }>>([])
const [parsedDatesLoading, setParsedDatesLoading] = useState(false)
// Non-stock confirmation modal state
const [showNonStockConfirmModal, setShowNonStockConfirmModal] = useState(false)
const [nonStockConflictItems, setNonStockConflictItems] = useState<Array<{ description: string | null; amount: number | null }>>([])
// Invoice number search modal state
const [showInvoiceNumberModal, setShowInvoiceNumberModal] = useState(false)
const [invoiceNumberCandidates, setInvoiceNumberCandidates] = useState<Array<{ value: string; context: string; source: string; bbox?: { x: number; y: number; width: number; height: number; pageNumber: number } }>>([])
const [invoiceNumberExamples, setInvoiceNumberExamples] = useState<string[]>([])
const [invoiceNumberLoading, setInvoiceNumberLoading] = useState(false)
// Line items sorting and filtering
const [lineItemSortColumn, setLineItemSortColumn] = useState<string>('')
const [lineItemSortDirection, setLineItemSortDirection] = useState<'asc' | 'desc'>('asc')
const [lineItemPriceFilter, setLineItemPriceFilter] = useState<string>('')
const [lineItemSearchText, setLineItemSearchText] = useState<string>('')
const [lineItemPortionsFilter, setLineItemPortionsFilter] = useState<string>('')
const [lineItemMissingDataFilter, setLineItemMissingDataFilter] = useState<string>('')
// Current visible page for sticky indicator (multi-page invoices)
const [currentVisiblePage, setCurrentVisiblePage] = useState<number>(1)
const lineItemsTableRef = useRef<HTMLDivElement>(null)
// Ingredient mapping modal state
const [ingredientModalItem, setIngredientModalItem] = useState<LineItem | null>(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 [showLegacyPortioning, setShowLegacyPortioning] = useState(false)
const [ingredientConversionDisplay, setIngredientConversionDisplay] = useState('')
// 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)
// LLM FEATURE — AI Assist state — see LLM-MANIFEST.md for removal instructions
const [aiMatchLoading, setAiMatchLoading] = useState(false)
const [aiMatchResults, setAiMatchResults] = useState<Array<{ id: number; name: string; confidence: number; reason: string }>>([])
const [aiAssistLoading, setAiAssistLoading] = useState(false)
const [aiAssistSuggestions, setAiAssistSuggestions] = useState<AiAssistSuggestions | null>(null)
const [aiReconciliationMatches, setAiReconciliationMatches] = useState<AiReconciliationMatch[]>([])
const [aiAssistError, setAiAssistError] = useState<string | null>(null)
const [aiDismissedCorrections, setAiDismissedCorrections] = useState<Set<string>>(new Set())
const [aiPackSizeLoading, setAiPackSizeLoading] = useState(false)
const [aiPackSizeSource, setAiPackSizeSource] = useState<string | null>(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<number | null>(null)
const { data: invoice, isLoading, refetch: refetchInvoice } = useQuery<Invoice>({
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<LineItem[]>({
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<Record<string, {
has_history: boolean
previously_non_stock: boolean
total_occurrences: number
non_stock_occurrences: number
most_recent_status: boolean | null
}>>({
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<DuplicateCompare>({
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<Settings>({
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<Supplier[]>({
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<Invoice>) => {
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<LineItem> }) => {
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<LineItem>) => {
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<LineItem> })
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<Invoice>)
} 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<LineItem>) => {
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<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) {
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<string, unknown> = {
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 <div style={styles.loading}>Loading invoice...</div>
}
if (!invoice) {
return <div style={styles.error}>Invoice not found</div>
}
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 (
<div style={styles.pageContainer}>
{/* Error banner for Azure OCR failures */}
{invoice?.ocr_raw_text && invoice.ocr_raw_text.startsWith('Error:') && (
<div style={styles.errorBanner}>
<div style={styles.errorBannerIcon}></div>
<div style={styles.errorBannerContent}>
<strong>OCR Processing Error</strong>
<p>{invoice.ocr_raw_text.replace('Error: ', '')}</p>
{invoice.ocr_raw_text.includes('quota exceeded') && (
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Next steps:</strong> Check your Azure subscription budget limits in the Azure portal.
Once resolved, use the "Reprocess" button below to retry OCR extraction.
</p>
)}
{invoice.ocr_raw_text.includes('authentication failed') && (
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
<strong>Next steps:</strong> Verify your Azure API credentials in Settings Azure Configuration.
</p>
)}
</div>
</div>
)}
{/* Processing banner for PENDING invoices */}
{invoice.status === 'PENDING' && (
<div style={{
display: 'flex',
gap: '1rem',
padding: '1rem 1.5rem',
marginBottom: '1.5rem',
background: '#d4edda',
border: '2px solid #28a745',
borderRadius: '8px',
color: '#155724',
alignItems: 'center',
}}>
<div style={{ fontSize: '1.5rem', flexShrink: 0 }}>&#9881;</div>
<div>
<strong>Processing Invoice</strong>
<p style={{ margin: '0.25rem 0 0' }}>Azure OCR extraction is in progress. The page will reload automatically when complete.</p>
</div>
</div>
)}
{/* Top row: Image and Form side by side */}
<div style={styles.topRow}>
<div style={styles.imageSection} ref={containerRef}>
<h3>Invoice {isPDF ? 'Document' : 'Image'}</h3>
{imageUrl ? (
isPDF ? (
<div style={styles.pdfScrollContainer} ref={pdfContainerRef}>
{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 (
<div key={pageIndex} style={styles.pdfPageWrapper}>
{/* Wrapper for image + highlights with actual size scaling for scrollable zoom */}
<div
data-page-container
style={{
...styles.pdfPageContainer,
position: 'relative',
width: page.displayWidth * (zoomPageNum === pageNum ? zoomLevel : 1),
height: page.displayHeight * (zoomPageNum === pageNum ? zoomLevel : 1),
margin: '0 auto',
}}>
<img
src={page.canvas.toDataURL()}
alt={`Page ${pageNum}`}
style={{
width: '100%',
height: '100%',
display: 'block',
}}
/>
{/* 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 (
<div
ref={highlightRef}
style={{
...styles.highlightOverlay,
left: `${fieldBbox.x - padding}%`,
top: `${fieldBbox.y - padding}%`,
width: `${fieldBbox.width + padding * 2}%`,
height: `${fieldBbox.height + padding * 2}%`,
border: `${borderWidth}px solid #ffc107`,
}}
onClick={() => { 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 (
<div
style={{
...styles.highlightOverlay,
left: `${lineItemBbox.x - padding}%`,
top: `${lineItemBbox.y - padding}%`,
width: `${lineItemBbox.width + padding * 2}%`,
height: `${lineItemBbox.height + padding * 2}%`,
border: `${borderWidth}px solid #ffc107`,
}}
onClick={() => { setExpandedLineItem(null); resetZoom(); }}
/>
)
})()}
</div>
{pdfPages.length > 1 && (
<div style={styles.pageLabel}>Page {pageNum} of {pdfPages.length}</div>
)}
</div>
)
})
) : (
<div style={styles.imagePlaceholder}>Rendering PDF...</div>
)}
</div>
) : (
<>
<div style={styles.imageZoomControls}>
<button onClick={() => setImageZoom(Math.min(imageZoom + 0.5, 5))} style={styles.zoomBtn} title="Zoom In">
+
</button>
<span style={styles.zoomLabel}>{Math.round(imageZoom * 100)}%</span>
<button onClick={() => setImageZoom(Math.max(imageZoom - 0.5, 1))} style={styles.zoomBtn} title="Zoom Out">
</button>
{imageZoom > 1 && (
<button onClick={() => setImageZoom(1)} style={styles.zoomResetBtnSmall} title="Reset Zoom">
Reset
</button>
)}
</div>
<div
style={{
...styles.imageScrollContainer,
cursor: imageZoom > 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)))
}
}}
>
<div style={{
...styles.imageContainer,
width: `${imageZoom * 100}%`,
height: 'auto'
}}>
<img
src={imageUrl}
alt="Invoice"
style={{
...styles.image,
width: '100%',
height: 'auto'
}}
/>
{highlightedField && getFieldBoundingBox(highlightedField) && (() => {
const bbox = getFieldBoundingBox(highlightedField)!
const padding = 0.5
return (
<div
style={{
...styles.highlightOverlay,
left: `${bbox.x - padding}%`,
top: `${bbox.y - padding}%`,
width: `${bbox.width + padding * 2}%`,
height: `${bbox.height + padding * 2}%`,
border: '2px solid #ffc107',
}}
onClick={() => setHighlightedField(null)}
/>
)
})()}
</div>
</div>
</>
)
) : (
<div style={styles.imagePlaceholder}>Loading {isPDF ? 'document' : 'image'}...</div>
)}
{isPDF && zoomLevel > 1 && (
<button
onClick={resetZoom}
style={styles.zoomResetBtn}
>
Reset Zoom ({zoomLevel}x)
</button>
)}
{isPDF && imageUrl && (
<a
href={`/kitchen/api/invoices/${id}/file?token=${encodeURIComponent(token || '')}`}
target="_blank"
rel="noopener noreferrer"
style={styles.openPdfLink}
>
Open PDF in new tab
</a>
)}
{confidence && (
<div style={styles.confidenceBadge}>
OCR Confidence: {confidence}%
</div>
)}
</div>
<div style={styles.formSection}>
{/* Duplicate Warning Banner */}
{invoice.duplicate_status && (
<div
style={{
...styles.duplicateWarning,
background: invoice.duplicate_status === 'firm_duplicate' ? '#f8d7da' : '#fff3cd',
borderColor: invoice.duplicate_status === 'firm_duplicate' ? '#f5c6cb' : '#ffeeba',
color: invoice.duplicate_status === 'firm_duplicate' ? '#721c24' : '#856404',
}}
onClick={() => setShowDuplicateModal(true)}
>
{invoice.duplicate_status === 'firm_duplicate'
? '⚠️ DUPLICATE: This invoice matches an existing record. Click to compare.'
: '⚠️ Possible duplicate detected. Click to compare.'}
</div>
)}
{/* PO Linked Indicator */}
{poMatchData?.linked_po && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
marginBottom: '1rem',
background: '#d4edda',
border: '1px solid #c3e6cb',
borderRadius: '8px',
color: '#155724',
fontSize: '0.9rem',
}}>
<div>
<strong>Linked to PO-{poMatchData.linked_po.po_id}</strong>
{' '}({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' }) : ''})
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => setViewingPoId(poMatchData.linked_po!.po_id)}
style={{ padding: '0.3rem 0.6rem', border: '1px solid #155724', borderRadius: '4px', background: 'white', color: '#155724', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
>
View PO
</button>
<button
onClick={() => handleUnlinkPo(poMatchData.linked_po!.po_id)}
style={{ padding: '0.3rem 0.6rem', border: '1px solid #856404', borderRadius: '4px', background: '#fff3cd', color: '#856404', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
>
Unlink
</button>
</div>
</div>
)}
{/* PO Matching Suggestions */}
{!poMatchData?.linked_po && poMatchData?.matches && poMatchData.matches.length > 0 && (
<div style={{
padding: '0.75rem 1rem',
marginBottom: '1rem',
background: '#e3f2fd',
border: '1px solid #bbdefb',
borderRadius: '8px',
fontSize: '0.9rem',
color: '#1565c0',
}}>
<div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>
This supplier has {poMatchData.matches.length} pending Purchase Order{poMatchData.matches.length > 1 ? 's' : ''}
</div>
{poMatchData.matches.map((m, idx) => {
const isSuggested = idx === 0 && m.confidence >= 0.8
const isPotential = !isSuggested && m.confidence >= 0.8
return (
<div key={m.po_id} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.4rem 0.5rem',
marginBottom: '0.25rem',
background: 'rgba(255,255,255,0.6)',
borderRadius: '4px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontWeight: 500 }}>PO-{m.po_id}:</span>
<span>{m.total_amount != null ? `£${m.total_amount.toFixed(2)}` : 'N/A'}</span>
<span style={{ color: '#666' }}>
({m.order_date ? new Date(m.order_date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : 'No date'})
</span>
{isSuggested && (
<span style={{
background: '#4caf50',
color: 'white',
padding: '0.1rem 0.4rem',
borderRadius: '3px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
Suggested match
</span>
)}
{isPotential && (
<span style={{
background: '#ff9800',
color: 'white',
padding: '0.1rem 0.4rem',
borderRadius: '3px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
Potential match
</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
onClick={() => setViewingPoId(m.po_id)}
style={{ padding: '0.2rem 0.5rem', border: '1px solid #1565c0', borderRadius: '4px', background: 'white', color: '#1565c0', cursor: 'pointer', fontSize: '0.8rem' }}
>
View
</button>
<button
onClick={() => handleLinkPo(m.po_id)}
style={{ padding: '0.2rem 0.5rem', border: 'none', borderRadius: '4px', background: '#1565c0', color: 'white', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
>
Link
</button>
</div>
</div>
)
})}
</div>
)}
{/* Header: Title | Source Badge | Status Badge */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0 }}>Invoice Details</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{invoice.source && invoice.source !== 'upload' && (
<SourceBadge source={invoice.source} sourceReference={invoice.source_reference} />
)}
<span style={{
padding: '0.4rem 0.8rem',
borderRadius: '4px',
fontSize: '0.85rem',
fontWeight: '600',
background: invoice.status === 'CONFIRMED' ? '#22c55e' : invoice.status === 'REVIEWED' ? '#8b5cf6' : invoice.status === 'PROCESSED' ? '#3b82f6' : '#f59e0b',
color: 'white'
}}>
{invoice.status.toUpperCase()}
{invoice.document_type === 'delivery_note' && ' (DN)'}
</span>
</div>
</div>
<div style={styles.form}>
{/* Supplier (full width) */}
<div style={styles.label}>
<span>Supplier</span>
<div style={styles.supplierRow}>
<select
value={supplierId}
onChange={(e) => setSupplierId(e.target.value)}
style={{
...styles.input,
flex: 1,
...(invoice?.supplier_match_type === 'fuzzy' && supplierId ? {
border: '2px solid #f0ad4e',
background: '#fff8e6'
} : {})
}}
>
<option value="">-- Select Supplier --</option>
{suppliers?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{getFieldBoundingBox('VendorName') && (
<button
type="button"
onClick={() => handleHighlightField('VendorName')}
style={{
...styles.eyeBtn,
...(highlightedField === 'VendorName' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
<button
type="button"
onClick={openCreateSupplierModal}
style={styles.createSupplierBtn}
title="Create new supplier"
>
+ New
</button>
</div>
{invoice?.supplier_match_type === 'fuzzy' && supplierId && invoice.vendor_name && (
<div style={styles.fuzzyMatchWarning}>
<span>Fuzzy match from "{invoice.vendor_name}" - please verify</span>
<button
type="button"
onClick={() => addAliasMutation.mutate({
supplierId: parseInt(supplierId),
alias: invoice.vendor_name!,
invoiceId: invoice.id
})}
style={styles.addAliasBtn}
disabled={addAliasMutation.isPending}
title="Add this name as an alias so future invoices match exactly"
>
{addAliasMutation.isPending ? 'Adding...' : '+ Add as alias'}
</button>
</div>
)}
{invoice?.vendor_name && !supplierId && (
<div style={styles.extractedHintRow}>
<span style={styles.extractedHint}>Extracted: {invoice.vendor_name}</span>
<button
type="button"
onClick={openCreateSupplierModal}
style={styles.createFromExtractedBtn}
>
Create "{invoice.vendor_name}"
</button>
</div>
)}
</div>
{/* Date | Type */}
<div style={styles.row}>
<label style={{ ...styles.label, flex: 1 }}>
Invoice Date
{(() => {
const dateWarning = getDateWarning(invoiceDate || null, invoice?.status || '');
return (
<>
<div style={styles.inputWithEye}>
<input
type="date"
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
style={{ ...styles.input, ...dateWarningStyles[dateWarning] }}
/>
{getFieldBoundingBox('InvoiceDate') && (
<button
type="button"
onClick={() => handleHighlightField('InvoiceDate')}
style={{
...styles.eyeBtn,
...(highlightedField === 'InvoiceDate' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
{invoice?.status !== 'CONFIRMED' && (
<button
type="button"
onClick={handleFetchParsedDates}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '1rem',
color: (!invoiceDate || dateWarning !== 'none') ? '#007bff' : '#ccc',
display: 'flex',
alignItems: 'center',
}}
title="Find dates in document"
>
🔍
</button>
)}
</div>
{dateWarning !== 'none' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{dateWarning === 'future' ? (
<button
type="button"
onClick={handleFetchParsedDates}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: '0.8rem',
color: '#e85d04',
fontWeight: 600,
textDecoration: 'underline',
textDecorationStyle: 'dotted',
}}
title="Click to fix date"
>
Date is in the future likely a day/month swap click to fix
</button>
) : (
<span style={{ fontSize: '0.8rem', color: dateWarning === 'red' ? '#dc3545' : '#856404' }}>
{!invoiceDate ? 'No date set' : 'Date differs from today by more than 7 days'}
</span>
)}
</div>
)}
</>
);
})()}
</label>
<label style={{ ...styles.label, flex: 1 }}>
Document Type
<select
value={documentType}
onChange={(e) => setDocumentType(e.target.value)}
style={styles.input}
>
<option value="invoice">Invoice</option>
<option value="credit_note">Credit Note</option>
<option value="delivery_note">Delivery Note</option>
</select>
</label>
</div>
{/* Number | PO */}
<div style={styles.row}>
<label style={{ ...styles.label, flex: 1 }}>
Invoice Number
<div style={styles.inputWithEye}>
<input
type="text"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
style={styles.input}
placeholder="e.g., INV-12345"
/>
{getFieldBoundingBox('InvoiceId') && (
<button
type="button"
onClick={() => handleHighlightField('InvoiceId')}
style={{
...styles.eyeBtn,
...(highlightedField === 'InvoiceId' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
{invoice?.status !== 'CONFIRMED' && (
<button
type="button"
onClick={handleFetchInvoiceNumberCandidates}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '1rem',
color: invoiceNumber ? '#ccc' : '#007bff',
display: 'flex',
alignItems: 'center',
}}
title="Search OCR for invoice number"
>
🔍
</button>
)}
</div>
</label>
<label style={{ ...styles.label, flex: 1 }}>
Order/PO Number
<div style={styles.inputWithEye}>
<input
type="text"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
style={styles.input}
placeholder="e.g., PO-12345"
/>
{getFieldBoundingBox('PurchaseOrder') && (
<button
type="button"
onClick={() => handleHighlightField('PurchaseOrder')}
style={{
...styles.eyeBtn,
...(highlightedField === 'PurchaseOrder' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
</div>
</label>
</div>
{/* Net | Gross */}
<div style={styles.row}>
<label style={{ ...styles.label, flex: 1 }}>
Net Total (£)
<div style={styles.inputWithEye}>
<input
type="number"
step="0.01"
value={netTotal}
onChange={(e) => setNetTotal(e.target.value)}
style={styles.input}
placeholder="Exc. VAT"
/>
{getFieldBoundingBox('SubTotal') && (
<button
type="button"
onClick={() => handleHighlightField('SubTotal')}
style={{
...styles.eyeBtn,
...(highlightedField === 'SubTotal' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
</div>
</label>
<label style={{ ...styles.label, flex: 1 }}>
Gross Total (£)
<div style={styles.inputWithEye}>
<input
type="number"
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
style={styles.input}
placeholder="Inc. VAT"
/>
{getFieldBoundingBox('InvoiceTotal') && (
<button
type="button"
onClick={() => handleHighlightField('InvoiceTotal')}
style={{
...styles.eyeBtn,
...(highlightedField === 'InvoiceTotal' ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
</div>
</label>
</div>
{/* Notes */}
<label style={styles.label}>
<span>
Invoice Notes{' '}
<small style={{ color: '#666', fontSize: '0.85em' }}>
(Optional, included in Dext etc.)
</small>
</span>
<textarea
value={invoiceNotes}
onChange={(e) => setInvoiceNotes(e.target.value)}
onBlur={async () => {
try {
await fetch(`/kitchen/api/invoices/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ notes: invoiceNotes })
})
} catch (error) {
console.error('Failed to save notes:', error)
}
}}
placeholder="Add notes about this invoice..."
rows={3}
style={{
...styles.input,
resize: 'vertical',
fontFamily: 'inherit'
}}
/>
</label>
</div>
{/* Confirm & Dext (full width, auto-saves on change) */}
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
{/* Link/Resolved Dispute button - only for credit notes with a supplier */}
{invoice?.document_type === 'credit_note' && invoice?.supplier_id && (
invoice?.linked_dispute_id ? (
<button
onClick={() => setViewingDisputeId(invoice.linked_dispute_id)}
style={{
padding: '0.75rem 1rem',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '0.9rem',
whiteSpace: 'nowrap'
}}
title="View the dispute resolved by this credit note"
>
Resolved Dispute
</button>
) : (
<button
onClick={() => setShowLinkDisputeModal(true)}
style={{
padding: '0.75rem 1rem',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '0.9rem',
whiteSpace: 'nowrap'
}}
title="Link this credit note to an open dispute"
>
Link Dispute
</button>
)
)}
<button
onClick={handleConfirm}
style={{
...styles.confirmBtn,
flex: 1,
opacity: (hasLineItemErrors || updateMutation.isPending) ? 0.5 : 1,
cursor: (hasLineItemErrors || updateMutation.isPending) ? 'not-allowed' : 'pointer'
}}
disabled={hasLineItemErrors || updateMutation.isPending}
title={hasLineItemErrors ? 'Fix line item validation errors before confirming' : ''}
>
{invoice?.dext_sent_at || invoice?.supplier_skip_dext ? 'CONFIRM' : 'CONFIRM & DEXT'}
</button>
</div>
{/* Checks Section */}
{lineItems && lineItems.length > 0 && (
<div style={{ margin: '20px 0', display: 'flex', gap: '8px' }}>
{/* Items Total Check - RED if doesn't match (blocks confirm) */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `2px solid ${lineItemStats.totalsMatch ? '#c3e6cb' : '#dc2626'}`,
background: lineItemStats.totalsMatch ? '#d4edda' : '#fef2f2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>
{lineItemStats.totalsMatch ? '✓' : '⛔'}
</div>
<div style={{ fontWeight: '600', color: lineItemStats.totalsMatch ? '#155724' : '#dc2626', marginBottom: '2px' }}>
Items Total
</div>
<div style={{ fontSize: '0.7rem', color: lineItemStats.totalsMatch ? '#155724' : '#dc2626' }}>
{lineItemStats.totalsMatch ? 'Matches' : "Doesn't match"}
</div>
</div>
{/* QTY/Price Check - RED if errors (blocks confirm) */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `2px solid ${lineItemStats.priceCalcErrors === 0 ? '#c3e6cb' : '#dc2626'}`,
background: lineItemStats.priceCalcErrors === 0 ? '#d4edda' : '#fef2f2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>
{lineItemStats.priceCalcErrors === 0 ? '✓' : '⛔'}
</div>
<div style={{ fontWeight: '600', color: lineItemStats.priceCalcErrors === 0 ? '#155724' : '#dc2626', marginBottom: '2px' }}>
QTY/Price
</div>
<div style={{ fontSize: '0.7rem', color: lineItemStats.priceCalcErrors === 0 ? '#155724' : '#dc2626' }}>
{lineItemStats.priceCalcErrors === 0 ? 'All OK' : `${lineItemStats.priceCalcErrors} error${lineItemStats.priceCalcErrors !== 1 ? 's' : ''}`}
</div>
</div>
{/* Portions Check - AMBER warning (doesn't block) */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `1px solid ${lineItemStats.withoutPortions === 0 ? '#c3e6cb' : '#ffc107'}`,
background: lineItemStats.withoutPortions === 0 ? '#d4edda' : '#fff3cd',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>📦</div>
<div style={{ fontWeight: '600', color: lineItemStats.withoutPortions === 0 ? '#155724' : '#856404', marginBottom: '2px' }}>
Portions
</div>
<div style={{ fontSize: '0.7rem', color: lineItemStats.withoutPortions === 0 ? '#155724' : '#856404' }}>
{lineItemStats.withPortions} / {lineItemStats.total}
</div>
</div>
{/* Missing Data Check - AMBER warning (doesn't block confirm) */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `1px solid ${lineItemStats.missingData === 0 ? '#c3e6cb' : '#ffc107'}`,
background: lineItemStats.missingData === 0 ? '#d4edda' : '#fff3cd',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>
{lineItemStats.missingData === 0 ? '✓' : '⚠'}
</div>
<div style={{ fontWeight: '600', color: lineItemStats.missingData === 0 ? '#155724' : '#856404', marginBottom: '2px' }}>
Missing Data
</div>
<div style={{ fontSize: '0.7rem', color: lineItemStats.missingData === 0 ? '#155724' : '#856404' }}>
{lineItemStats.missingData} item{lineItemStats.missingData !== 1 ? 's' : ''}
</div>
</div>
{/* Non-Stock Check */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `1px solid ${lineItemStats.nonStock === 0 ? '#c3e6cb' : '#ffc107'}`,
background: lineItemStats.nonStock === 0 ? '#d4edda' : '#fff3cd',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>🚫</div>
<div style={{ fontWeight: '600', color: lineItemStats.nonStock === 0 ? '#155724' : '#856404', marginBottom: '2px' }}>
Non-Stock
</div>
<div style={{ fontSize: '0.7rem', color: lineItemStats.nonStock === 0 ? '#155724' : '#856404' }}>
{lineItemStats.nonStock} item{lineItemStats.nonStock !== 1 ? 's' : ''}
</div>
</div>
{/* Dext Status Check - Always visible */}
<div style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: `1px solid ${invoice.supplier_skip_dext ? '#c3e6cb' : (invoice.dext_sent_at ? '#c3e6cb' : '#ffc107')}`,
background: invoice.supplier_skip_dext ? '#d4edda' : (invoice.dext_sent_at ? '#d4edda' : '#fff3cd'),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
fontSize: '0.8rem'
}}>
<div style={{ fontSize: '1.3rem', marginBottom: '2px' }}>
{invoice.supplier_skip_dext ? '' : (invoice.dext_sent_at ? '✓' : '⚠')}
</div>
<div style={{ fontWeight: '600', color: invoice.supplier_skip_dext ? '#155724' : (invoice.dext_sent_at ? '#155724' : '#856404'), marginBottom: '2px' }}>
Dext Status
</div>
<div style={{ fontSize: '0.7rem', color: invoice.supplier_skip_dext ? '#155724' : (invoice.dext_sent_at ? '#155724' : '#856404') }}>
{invoice.supplier_skip_dext ? 'N/A (Skipped)' : (invoice.dext_sent_at ? new Date(invoice.dext_sent_at).toLocaleDateString() : 'Not sent')}
</div>
</div>
</div>
)}
{/* Dispute Indicator Blocks - clickable to open detail modal */}
{invoice.disputes && invoice.disputes.length > 0 && (
<div style={{ marginTop: '15px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{invoice.disputes.map((dispute) => {
const isOpen = ['NEW', 'CONTACTED', 'AWAITING_CREDIT', 'AWAITING_REPLACEMENT'].includes(dispute.status)
return (
<div
key={dispute.id}
onClick={() => setViewingDisputeId(dispute.id)}
style={{
padding: '12px',
background: isOpen ? '#fff3cd' : '#d1ecf1',
border: `2px solid ${isOpen ? '#ffc107' : '#17a2b8'}`,
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
>
<div>
<div style={{ fontWeight: 'bold', color: '#1a1a2e', marginBottom: '4px' }}>
{isOpen ? '⚠️' : ''} {dispute.status.replace(/_/g, ' ')}
</div>
<div style={{ fontSize: '0.8rem', color: '#666' }}>
#{dispute.id} - {dispute.dispute_type.replace(/_/g, ' ').toUpperCase()} - {dispute.title}
</div>
</div>
<span
style={{
padding: '6px 12px',
background: '#e94560',
color: 'white',
borderRadius: '4px',
fontSize: '0.85rem',
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}
>
View
</span>
</div>
)
})}
</div>
)}
{/* Button Rows - Centered */}
<div style={{ marginTop: '15px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
{/* Row 1: Resend to Dext | Delete | View OCR */}
<div style={{ display: 'flex', gap: '10px' }}>
{(invoice.status === 'CONFIRMED' || invoice.status === 'REVIEWED') && settings?.dext_manual_send_enabled && !invoice.supplier_skip_dext && (
<button
onClick={() => setShowDextSendConfirm(true)}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: '#17a2b8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: hasLineItemErrors ? 'not-allowed' : 'pointer',
minWidth: '130px',
whiteSpace: 'nowrap',
opacity: hasLineItemErrors ? 0.5 : 1
}}
disabled={hasLineItemErrors}
title={hasLineItemErrors ? 'Fix line item validation errors before sending to Dext' : ''}
>
{invoice.dext_sent_at ? 'Resend to Dext' : 'Send to Dext'}
</button>
)}
<button
onClick={() => setShowDeleteModal(true)}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
minWidth: '110px',
whiteSpace: 'nowrap'
}}
>
Delete Invoice
</button>
<button
onClick={() => setShowDisputeModal(true)}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: '#f0ad4e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
minWidth: '110px',
whiteSpace: 'nowrap'
}}
>
Log Dispute
</button>
{/* LLM FEATURE — AI Assist button — see LLM-MANIFEST.md for removal instructions */}
{settings?.llm_enabled && settings?.anthropic_api_key_set && (
<button
onClick={handleAiAssist}
disabled={aiAssistLoading}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: aiAssistSuggestions ? '#6f42c1' : '#7952b3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: aiAssistLoading ? 'wait' : 'pointer',
minWidth: '120px',
whiteSpace: 'nowrap',
opacity: aiAssistLoading ? 0.7 : 1,
}}
>
{aiAssistLoading ? 'Analysing...' : aiAssistSuggestions ? '\u2728 AI Results' : '\u2728 AI Assist'}
</button>
)}
<button
onClick={() => setShowRawOcrModal(true)}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
minWidth: '120px',
whiteSpace: 'nowrap'
}}
>
View OCR Data
</button>
</div>
{/* Row 2: Admin Only Manual Control Buttons */}
{user?.is_admin && (
<div style={{ display: 'flex', gap: '10px' }}>
{!invoice.supplier_skip_dext && (
<button
onClick={handleMarkDextSent}
disabled={adminOperationInProgress}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: adminOperationInProgress ? '#ccc' : '#17a2b8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: adminOperationInProgress ? 'not-allowed' : 'pointer',
minWidth: '140px',
whiteSpace: 'nowrap'
}}
>
{adminOperationInProgress ? 'Processing...' : 'Mark Dext Sent'}
</button>
)}
<button
onClick={handleReprocessOCR}
disabled={adminOperationInProgress}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: adminOperationInProgress ? '#ccc' : '#ffc107',
color: '#000',
border: 'none',
borderRadius: '4px',
cursor: adminOperationInProgress ? 'not-allowed' : 'pointer',
minWidth: '130px',
whiteSpace: 'nowrap'
}}
>
{adminOperationInProgress ? 'Processing...' : 'Reprocess OCR'}
</button>
<button
onClick={handleResendToAzure}
disabled={adminOperationInProgress}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: adminOperationInProgress ? '#ccc' : '#fd7e14',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: adminOperationInProgress ? 'not-allowed' : 'pointer',
minWidth: '150px',
whiteSpace: 'nowrap'
}}
>
{adminOperationInProgress ? 'Processing...' : 'Resend to Azure'}
</button>
<button
onClick={handleRegenerateHighlights}
disabled={adminOperationInProgress}
style={{
padding: '0.4rem 1.2rem',
fontSize: '0.8rem',
background: adminOperationInProgress ? '#ccc' : '#6f42c1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: adminOperationInProgress ? 'not-allowed' : 'pointer',
minWidth: '160px',
whiteSpace: 'nowrap'
}}
title="Regenerate PDF highlight annotations for non-stock items"
>
{adminOperationInProgress ? 'Processing...' : 'Regen Highlights'}
</button>
</div>
)}
{/* Admin Operation Result Alert */}
{adminOperationResult && (
<div style={{
marginTop: '10px',
padding: '12px',
borderRadius: '4px',
background: adminOperationResult.type === 'success' ? '#d4edda' : '#f8d7da',
color: adminOperationResult.type === 'success' ? '#155724' : '#721c24',
border: `1px solid ${adminOperationResult.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`,
fontSize: '0.85rem',
whiteSpace: 'pre-wrap',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<span>{adminOperationResult.message}</span>
<button
onClick={() => setAdminOperationResult(null)}
style={{
background: 'transparent',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
marginLeft: '10px',
color: 'inherit'
}}
>
×
</button>
</div>
)}
</div>
</div>
</div>
{/* LLM FEATURE — AI Assist suggestions panel — see LLM-MANIFEST.md for removal instructions */}
{(aiAssistSuggestions || aiAssistError || aiReconciliationMatches.length > 0) && (
<div style={{
background: '#f8f5ff',
border: '1px solid #d4c5f9',
borderRadius: '8px',
padding: '1rem',
marginBottom: '1rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ margin: 0, color: '#5a32a3', fontSize: '1rem' }}>{'\u2728'} AI Analysis</h3>
<button
onClick={() => { setAiAssistSuggestions(null); setAiReconciliationMatches([]); setAiAssistError(null) }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem', color: '#999' }}
>{'\u2715'}</button>
</div>
{aiAssistError && (
<div style={{ background: '#f8d7da', color: '#721c24', padding: '0.5rem 0.75rem', borderRadius: '4px', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
{aiAssistError}
</div>
)}
{/* Supplier match */}
{aiAssistSuggestions?.supplier_match && !invoice.supplier_id && (
<div style={{ background: '#d1ecf1', padding: '0.5rem 0.75rem', borderRadius: '4px', marginBottom: '0.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.85rem', color: '#0c5460' }}>
Supplier: <strong>{aiAssistSuggestions.supplier_match.name}</strong> ({(aiAssistSuggestions.supplier_match.confidence * 100).toFixed(0)}% confident) {aiAssistSuggestions.supplier_match.reason}
</span>
<button
onClick={() => handleApplyAiSupplier(aiAssistSuggestions.supplier_match!.id)}
style={{ padding: '0.25rem 0.75rem', background: '#17a2b8', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' }}
>Accept</button>
</div>
)}
{/* Total mismatch analysis */}
{aiAssistSuggestions?.total_mismatch_analysis && (
<div style={{ background: '#fff3cd', padding: '0.5rem 0.75rem', borderRadius: '4px', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#856404' }}>
<strong>Total mismatch:</strong> {aiAssistSuggestions.total_mismatch_analysis}
</div>
)}
{/* VAT treatment */}
{aiAssistSuggestions?.vat_treatment && (
<div style={{ background: '#e2e3e5', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#383d41' }}>
VAT treatment: <strong>{aiAssistSuggestions.vat_treatment}</strong>
</div>
)}
{/* Line item corrections */}
{aiAssistSuggestions?.line_item_corrections && aiAssistSuggestions.line_item_corrections.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#5a32a3', marginBottom: '0.35rem' }}>Line Item Corrections</div>
{aiAssistSuggestions.line_item_corrections
.filter(c => !aiDismissedCorrections.has(`${c.idx}-${c.field}`))
.map((c, i) => (
<div key={i} style={{ background: '#ffe6e6', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: '#721c24' }}>
Line {c.idx + 1}: <strong>{c.field}</strong> {c.current} {'\u2192'} {c.suggested} {c.reason}
</span>
<div style={{ display: 'flex', gap: '0.35rem' }}>
<button
onClick={() => handleApplyAiCorrection(c.idx, c.field, c.suggested)}
style={{ padding: '0.2rem 0.5rem', background: '#28a745', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Apply</button>
<button
onClick={() => setAiDismissedCorrections(prev => new Set([...prev, `${c.idx}-${c.field}`]))}
style={{ padding: '0.2rem 0.5rem', background: '#6c757d', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Dismiss</button>
</div>
</div>
))}
</div>
)}
{/* Subtotal flags */}
{aiAssistSuggestions?.subtotal_flags && aiAssistSuggestions.subtotal_flags.length > 0 && lineItems && (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#5a32a3', marginBottom: '0.35rem' }}>Subtotal / Fee Rows</div>
{aiAssistSuggestions.subtotal_flags.map((idx) => {
const item = lineItems[idx]
if (!item) return null
return (
<div key={idx} style={{ background: '#fff3cd', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: '#856404' }}>
Line {idx + 1}: &quot;{item.description?.substring(0, 50)}&quot; appears to be a subtotal or fee, not a product
</span>
{!item.is_non_stock && (
<button
onClick={() => updateLineItemMutation.mutate({ itemId: item.id, data: { is_non_stock: true } as Partial<LineItem> })}
style={{ padding: '0.2rem 0.5rem', background: '#f0ad4e', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Mark Non-Stock</button>
)}
</div>
)
})}
</div>
)}
{/* Description recommendations */}
{aiAssistSuggestions?.description_recommendations && aiAssistSuggestions.description_recommendations.length > 0 && lineItems && (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#5a32a3', marginBottom: '0.35rem' }}>Description Recommendations</div>
{aiAssistSuggestions.description_recommendations
.filter(r => r.recommendation !== 'keep')
.map((r, i) => {
const item = lineItems[r.idx]
if (!item || !item.description_alt) return null
return (
<div key={i} style={{ background: '#d1ecf1', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: '#0c5460' }}>
Line {r.idx + 1}: Swap description recommended {r.reason}
</span>
<button
onClick={() => updateLineItemMutation.mutate({
itemId: item.id,
data: { description: item.description_alt, description_alt: item.description } as Partial<LineItem>
})}
style={{ padding: '0.2rem 0.5rem', background: '#17a2b8', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Swap</button>
</div>
)
})}
</div>
)}
{/* LLM FEATURE — Pack size suggestions */}
{aiAssistSuggestions?.pack_size_suggestions && aiAssistSuggestions.pack_size_suggestions.length > 0 && lineItems && (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#5a32a3', marginBottom: '0.35rem' }}>Pack Size (from description)</div>
{aiAssistSuggestions.pack_size_suggestions.map((p, i) => {
const item = lineItems[p.idx]
if (!item) return null
const packLabel = p.pack_quantity > 1
? `${p.pack_quantity} × ${p.unit_size}${p.unit_size_type}`
: `${p.unit_size}${p.unit_size_type}`
const alreadySet = item.pack_quantity && item.unit_size
return (
<div key={i} style={{ background: alreadySet ? '#e8e8e8' : '#e8daef', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: alreadySet ? '#666' : '#4a235a' }}>
Line {p.idx + 1}: &quot;{item.description?.substring(0, 40)}&quot; <strong>{packLabel}</strong>
{alreadySet && ' (already set)'}
</span>
{!alreadySet && (
<button
onClick={() => updateLineItemMutation.mutate({
itemId: item.id,
data: { pack_quantity: p.pack_quantity, unit_size: p.unit_size, unit_size_type: p.unit_size_type } as Partial<LineItem>
})}
style={{ padding: '0.2rem 0.5rem', background: '#7d3c98', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Apply</button>
)}
</div>
)
})}
</div>
)}
{/* END LLM FEATURE */}
{/* Reconciliation matches */}
{aiReconciliationMatches.length > 0 && lineItems && (
<div>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#5a32a3', marginBottom: '0.35rem' }}>Ingredient Matches (from supplier history)</div>
{aiReconciliationMatches.map((m, i) => {
const item = lineItems[m.idx]
if (!item) return null
return (
<div key={i} style={{ background: '#d4edda', padding: '0.4rem 0.75rem', borderRadius: '4px', marginBottom: '0.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: '#155724' }}>
Line {m.idx + 1} &quot;{item.description?.substring(0, 40)}&quot; {'\u2192'} <strong>{m.ingredient_name}</strong> ({(m.confidence * 100).toFixed(0)}%) {m.reason}
</span>
<button
onClick={() => updateLineItemMutation.mutate({ itemId: item.id, data: { ingredient_id: m.ingredient_id } as Partial<LineItem> })}
style={{ padding: '0.2rem 0.5rem', background: '#28a745', color: 'white', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.75rem' }}
>Map</button>
</div>
)
})}
</div>
)}
</div>
)}
{/* Full-width Line Items Section */}
<div style={styles.lineItemsSection}>
<div style={{ marginBottom: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<h3 style={{ margin: '0' }}>Line Items</h3>
<button
onClick={() => {
if (bulkEditMode) {
// Check if there are unsaved changes
if (editingLineItem !== null) {
if (!window.confirm('You have unsaved changes. Are you sure you want to exit edit mode?')) {
return
}
}
// Exiting bulk edit mode - cancel any unsaved edits
setEditingLineItem(null)
setLineItemEdits({})
setBulkEditMode(false)
} else {
// Entering bulk edit mode
setBulkEditMode(true)
// If there are no line items, automatically add one
if (!lineItems || lineItems.length === 0) {
handleAddLineItem()
}
}
}}
style={{
...styles.editBtn,
background: bulkEditMode ? '#dc3545' : '#f0f0f0',
color: bulkEditMode ? 'white' : '#333',
border: bulkEditMode ? 'none' : '1px solid #ddd',
padding: '0.5rem 1rem',
fontSize: '0.85rem'
}}
>
{bulkEditMode ? '✕ Exit Edit Mode' : '✏️ Edit Line Items'}
</button>
</div>
{lineItems && lineItems.length > 0 && (
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="text"
placeholder="Search by code or description..."
value={lineItemSearchText}
onChange={(e) => setLineItemSearchText(e.target.value)}
style={{
flex: 1,
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid #d1d5db',
fontSize: '0.9rem'
}}
/>
<label style={{ fontSize: '0.9rem', color: '#6b7280' }}>
Price:
<select
value={lineItemPriceFilter}
onChange={(e) => setLineItemPriceFilter(e.target.value)}
style={{ marginLeft: '5px', padding: '4px 8px', borderRadius: '4px', border: '1px solid #d1d5db' }}
>
<option value="">All</option>
<option value="consistent" disabled={filterOptionCounts.consistent === 0}> Consistent</option>
<option value="amber" disabled={filterOptionCounts.amber === 0}>? Changed</option>
<option value="red" disabled={filterOptionCounts.red === 0}>! Large change</option>
<option value="no_history" disabled={filterOptionCounts.no_history === 0}>No history</option>
</select>
</label>
<label style={{ fontSize: '0.9rem', color: '#6b7280' }}>
Portions:
<select
value={lineItemPortionsFilter}
onChange={(e) => setLineItemPortionsFilter(e.target.value)}
style={{ marginLeft: '5px', padding: '4px 8px', borderRadius: '4px', border: '1px solid #d1d5db' }}
>
<option value="">All</option>
<option value="yes" disabled={filterOptionCounts.withPortions === 0}>📦 Yes</option>
<option value="no" disabled={filterOptionCounts.withoutPortions === 0}> No</option>
</select>
</label>
<label style={{ fontSize: '0.9rem', color: '#6b7280' }}>
Data:
<select
value={lineItemMissingDataFilter}
onChange={(e) => setLineItemMissingDataFilter(e.target.value)}
style={{ marginLeft: '5px', padding: '4px 8px', borderRadius: '4px', border: '1px solid #d1d5db' }}
>
<option value="">All</option>
<option value="missing" disabled={filterOptionCounts.missingData === 0}> Missing key data</option>
</select>
</label>
{(lineItemSortColumn || lineItemPriceFilter || lineItemSearchText || lineItemPortionsFilter || lineItemMissingDataFilter) && (
<button
onClick={() => {
setLineItemSortColumn('')
setLineItemPriceFilter('')
setLineItemSearchText('')
setLineItemPortionsFilter('')
setLineItemMissingDataFilter('')
}}
style={{
padding: '4px 10px',
fontSize: '0.85rem',
color: '#6b7280',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Clear
</button>
)}
</div>
)}
</div>
{(lineItems && lineItems.length > 0) || bulkEditMode ? (
<div style={styles.lineItemsTableContainer} ref={lineItemsTableRef}>
<table style={styles.lineItemsTable}>
<thead style={styles.stickyThead}>
<tr>
<th style={{ ...styles.th, width: '30px' }}></th>
<th
style={{ ...styles.th, width: '80px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('code')}
>
Code {lineItemSortColumn === 'code' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ ...styles.th, minWidth: '240px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('description')}
>
Description {lineItemSortColumn === 'description' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ ...styles.th, width: '50px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('unit')}
>
Unit {lineItemSortColumn === 'unit' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ ...styles.th, width: '50px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('quantity')}
>
Qty {lineItemSortColumn === 'quantity' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ ...styles.th, width: '110px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('unit_price')}
>
Price {lineItemSortColumn === 'unit_price' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ ...styles.th, width: '50px' }}>Tax</th>
<th
style={{ ...styles.th, width: '70px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleLineItemSort('amount')}
>
Total {lineItemSortColumn === 'amount' && (lineItemSortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ ...styles.th, textAlign: 'center', width: '60px', cursor: 'pointer', userSelect: 'none' }}
onClick={() => setShowBulkStockModal(true)}
title="Click to mark all items as stock or non-stock"
>
Stock
</th>
<th style={{ ...styles.th, textAlign: 'center', width: '40px' }} title="Cost Breakdown"></th>
<th style={{ ...styles.th, width: '70px' }}></th>
</tr>
{/* Sticky page indicator row for multi-page invoices */}
{invoice && invoice.total_pages && invoice.total_pages > 1 && !lineItemSortColumn && !lineItemSearchText && !lineItemPriceFilter && !lineItemPortionsFilter && !lineItemMissingDataFilter && (
<tr>
<th colSpan={11} style={styles.stickyPageIndicatorCell}>
Page {currentVisiblePage} of {invoice.total_pages}
</th>
</tr>
)}
</thead>
<tbody>
{[...filteredAndSortedLineItems, ...(editingLineItem === -1 ? [{
id: -1,
product_code: lineItemEdits.product_code || '',
description: lineItemEdits.description || '',
description_alt: null,
unit: lineItemEdits.unit || '',
quantity: lineItemEdits.quantity ?? null,
order_quantity: null,
unit_price: lineItemEdits.unit_price ?? null,
tax_rate: lineItemEdits.tax_rate || '',
tax_amount: null,
amount: lineItemEdits.amount ?? null,
line_number: lineItemEdits.line_number || 0,
is_non_stock: lineItemEdits.is_non_stock || false,
raw_content: null,
pack_quantity: null,
unit_size: null,
unit_size_type: null,
portions_per_unit: null,
cost_per_item: null,
cost_per_portion: null,
ocr_warnings: null,
price_change_status: null,
price_change_percent: null,
previous_price: null,
future_price: null,
future_change_percent: null,
page_number: null,
_originalIndex: -1
}] : [])].map((item, index, allItems) => {
const bbox = getLineItemBoundingBox(item._originalIndex)
const highQtyThreshold = settings?.high_quantity_threshold ?? 100
const isHighQuantity = item.quantity !== null && item.quantity > highQtyThreshold
// Check for stock status conflict
const itemStockHistory = stockHistory?.[item.id.toString()]
const hasStockConflict = itemStockHistory?.has_history &&
itemStockHistory.previously_non_stock &&
!item.is_non_stock // Current item is marked as stock, but was previously non-stock
// Check for price calculation errors
// Valid cases: qty only (free item), or all three values with correct calculation
// For credit notes, totals are negative but qty/price are positive
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
const hasPriceCalcError =
(hasPrice && (!hasQty || !hasTotal)) || // price requires both qty and total
(hasTotal && (!hasQty || !hasPrice)) || // total requires both qty and price
(hasQty && hasPrice && hasTotal && calculationMismatch) // calculation mismatch (2p tolerance)
// Check if this line item is disputed
const isDisputed = invoice.disputed_line_item_ids?.includes(item.id)
// Check if this is the last item on its page (for page break indicator)
const isLastOnPage = invoice && invoice.total_pages && invoice.total_pages > 1 &&
!lineItemSortColumn && !lineItemSearchText && !lineItemPriceFilter && !lineItemPortionsFilter && !lineItemMissingDataFilter &&
item.page_number && index < allItems.length - 1 &&
allItems[index + 1]?.page_number !== item.page_number
// High quantity is lighter amber, disputed is pale pink (OCR warning now shown on product code cell only)
const rowBackground = isHighQuantity ? '#fff8e1' : isDisputed ? '#fff0f3' : undefined
const rowStyle: React.CSSProperties | undefined = rowBackground ? { backgroundColor: rowBackground } : undefined
// Add amber border for stock conflicts, or dashed border for page breaks
let rowStyleWithBorder: React.CSSProperties | undefined = hasStockConflict
? { ...rowStyle, border: '2px solid #ffc107' }
: rowStyle
if (isLastOnPage && !hasStockConflict) {
rowStyleWithBorder = { ...rowStyleWithBorder, borderBottom: '2px dashed #adb5bd' }
}
// Style for cells with price calculation errors - create a single box spanning qty to total
const errorCellStyleLeft = hasPriceCalcError
? { borderLeft: '2px solid #dc3545', borderTop: '2px solid #dc3545', borderBottom: '2px solid #dc3545', backgroundColor: '#ffe6e6' }
: {}
const errorCellStyleMiddle = hasPriceCalcError
? { borderTop: '2px solid #dc3545', borderBottom: '2px solid #dc3545', backgroundColor: '#ffe6e6' }
: {}
const errorCellStyleRight = hasPriceCalcError
? { borderRight: '2px solid #dc3545', borderTop: '2px solid #dc3545', borderBottom: '2px solid #dc3545', backgroundColor: '#ffe6e6' }
: {}
return (
<React.Fragment key={item.id}>
<tr style={rowStyleWithBorder} data-page={item.page_number || 1}>
<td style={styles.td}>
{bbox && (
<button
type="button"
onClick={() => handleHighlightLineItem(item.id, item._originalIndex)}
style={{
...styles.eyeBtn,
padding: '0.25rem 0.4rem',
fontSize: '0.8rem',
...(expandedLineItem === item.id ? styles.eyeBtnActive : {})
}}
title="Show on document"
>
👁
</button>
)}
</td>
{editingLineItem === item.id ? (
<>
<td style={styles.td}>
<input
type="text"
value={editingLineItem === item.id ? (lineItemEdits.product_code || '') : (item.product_code || '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, product_code: e.target.value }); }}
style={{ ...styles.tableInput, width: '70px' }}
/>
</td>
<td style={styles.td}>
<input
type="text"
value={editingLineItem === item.id ? (lineItemEdits.description || '') : (item.description || '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, description: e.target.value }); }}
style={styles.tableInput}
/>
</td>
<td style={styles.td}>
<input
type="text"
value={editingLineItem === item.id ? (lineItemEdits.unit || '') : (item.unit || '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, unit: e.target.value }); }}
style={{ ...styles.tableInput, width: '50px' }}
/>
</td>
<td style={styles.td}>
<input
type="number"
step="0.01"
value={editingLineItem === item.id ? (lineItemEdits.quantity ?? '') : (item.quantity ?? '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, quantity: parseFloat(e.target.value) }); }}
style={{ ...styles.tableInput, width: '50px' }}
/>
</td>
<td style={styles.td}>
<input
type="number"
step="0.01"
value={editingLineItem === item.id ? (lineItemEdits.unit_price ?? '') : (item.unit_price ?? '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, unit_price: parseFloat(e.target.value) }); }}
style={{ ...styles.tableInput, width: '70px' }}
/>
</td>
<td style={styles.td}>
<input
type="text"
value={editingLineItem === item.id ? (lineItemEdits.tax_rate || '') : (item.tax_rate || '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, tax_rate: e.target.value }); }}
style={{ ...styles.tableInput, width: '50px' }}
/>
</td>
<td style={styles.td}>
<input
type="number"
step="0.01"
value={editingLineItem === item.id ? (lineItemEdits.amount ?? '') : (item.amount ?? '')}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, amount: parseFloat(e.target.value) }); }}
style={{ ...styles.tableInput, width: '60px' }}
/>
</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<input
type="checkbox"
checked={editingLineItem === item.id ? !lineItemEdits.is_non_stock : !item.is_non_stock}
onChange={(e) => { ensureEditing(item); setLineItemEdits({ ...lineItemEdits, is_non_stock: !e.target.checked }); }}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
{/* Scale icon disabled during edit */}
</td>
<td style={styles.td}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<button
onClick={() => saveLineItemEdit(item.id)}
style={{
padding: '0.35rem 0.5rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '32px',
height: '32px'
}}
title="Save changes"
>
</button>
<button
onClick={cancelEditLineItem}
style={{
padding: '0.35rem 0.5rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '32px',
height: '32px'
}}
title="Cancel editing"
>
</button>
</div>
</td>
</>
) : (
<>
<td style={{ ...styles.td, fontSize: '0.85rem', color: '#666' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<span
style={item.ocr_warnings ? {
background: '#fff3cd',
padding: '2px 6px',
borderRadius: '3px',
border: '1px solid #ffc107'
} : undefined}
>
{item.product_code || '—'}
</span>
{item.ocr_warnings && (
<span
title={item.ocr_warnings}
style={{
cursor: 'help',
fontSize: '1rem'
}}
>
</span>
)}
</span>
</td>
<td style={{ ...styles.td, ...(item.is_non_stock ? { color: '#856404', fontStyle: 'italic' } : {}) }}>
<div>
{getFirstLineOfDescription(item.description) || '—'}
{item.description_alt && item.description !== item.description_alt && (
<button
onClick={(e) => { e.stopPropagation(); setDescSwapItem(item); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 6px',
fontSize: '0.9rem',
color: '#e94560',
marginLeft: '6px'
}}
title="OCR detected alternative description - click to compare"
>
🔄
</button>
)}
{invoice.disputed_line_item_ids?.includes(item.id) && (
<span style={{
marginLeft: '8px',
color: '#e94560',
fontSize: '0.7rem',
fontWeight: 'bold',
padding: '2px 6px',
background: '#ffe0e6',
borderRadius: '3px'
}}>
[DISPUTED]
</span>
)}
</div>
{hasStockConflict && (
<div style={{
fontSize: '0.75rem',
color: '#856404',
fontStyle: 'italic',
marginTop: '4px',
lineHeight: '1.2'
}}>
This item has previously been marked as not a stock item
</div>
)}
{hasPriceCalcError && (
<div style={{
fontSize: '0.75rem',
color: '#dc3545',
fontWeight: 'bold',
marginTop: '4px',
lineHeight: '1.3'
}}>
! QTY x Price = Total Check Failed<br />
{item.quantity?.toFixed(2)} × £{item.unit_price?.toFixed(2)} £{item.amount?.toFixed(2)}
</div>
)}
{/* Description alias suggestion banner */}
{!item.ingredient_id && !item.product_code && item.description && (() => {
const firstLine = item.description.split('\n')[0].trim().toLowerCase()
const suggestion = aliasSuggestions[firstLine]
if (!suggestion) return null
const isAdding = addingAliasFor === firstLine
return (
<div style={{
fontSize: '0.75rem',
color: '#0c5460',
background: '#d1ecf1',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
marginTop: '4px',
border: '1px solid #bee5eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
flexWrap: 'wrap',
}}>
<span>
Similar to "{suggestion.canonical_description}"
{suggestion.ingredient_name && <> {suggestion.ingredient_name}</>}
{' '}({Math.round(suggestion.similarity * 100)}% match)
{suggestion.price_difference != null && suggestion.price_difference > 5 && (
<span style={{ color: '#856404', marginLeft: '6px' }}>
Price differs {suggestion.price_difference.toFixed(0)}%
</span>
)}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setAddingAliasFor(firstLine)
addDescriptionAliasMutation.mutate({
sourceId: suggestion.source_id,
alias: item.description!.split('\n')[0].trim(),
})
}}
disabled={isAdding}
style={{
padding: '0.2rem 0.5rem',
background: '#17a2b8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isAdding ? 'default' : 'pointer',
fontSize: '0.7rem',
fontWeight: '500',
whiteSpace: 'nowrap' as const,
opacity: isAdding ? 0.6 : 1,
}}
>
{isAdding ? 'Adding...' : '+ Add as alias'}
</button>
</div>
)
})()}
</td>
<td style={{ ...styles.td, fontSize: '0.85rem' }}>{item.unit || '—'}</td>
<td style={{ ...styles.td, ...errorCellStyleLeft }}>{item.quantity?.toFixed(2) || '—'}</td>
<td style={{ ...styles.td, ...errorCellStyleMiddle, position: 'relative' }}>
{/* Price value - inline-block to align with other cells */}
<div style={{ display: 'inline-block', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>
{item.unit_price != null ? `£${item.unit_price.toFixed(2)}` : '—'}
{/* Show green tick inline for consistent prices */}
{item.price_change_status === 'consistent' && (
<span style={{ marginLeft: '6px', color: '#22c55e', fontWeight: 'bold' }}>
</span>
)}
{/* Show green document icon inline for new items (no history) */}
{item.price_change_status === 'no_history' && (
<span style={{ marginLeft: '6px', color: '#22c55e' }}>
📄
</span>
)}
{/* Show amber coin icon inline for items with price changes */}
{item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_status !== 'consistent' && (
<span style={{ marginLeft: '6px', color: '#f59e0b' }}>
🪙
</span>
)}
</div>
{/* Show arrow and percentage below for price changes - absolutely positioned */}
{item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_status !== 'consistent' && 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'
return (
<div
onClick={() => openPriceHistoryModal(item)}
style={{
position: 'absolute',
bottom: '0.2rem',
left: '0.5rem',
fontSize: '0.65rem',
lineHeight: '1',
color,
cursor: 'pointer'
}}
title="View price history"
>
<span style={{ fontWeight: 'bold' }}>{arrow}</span>{' '}
{Math.abs(item.price_change_percent).toFixed(1)}%
</div>
)
})()}
{/* Show future price change in brackets and grey for old invoices */}
{item.future_change_percent !== null && item.future_change_percent !== 0 && (() => {
const isIncrease = item.future_change_percent > 0
const arrow = isIncrease ? '▲' : '▼'
return (
<div
onClick={() => openPriceHistoryModal(item)}
style={{
position: 'absolute',
bottom: '0.2rem',
left: '0.5rem',
fontSize: '0.75rem',
color: '#9ca3af',
cursor: 'pointer'
}}
title="Price changed after this invoice"
>
(<span style={{ fontWeight: 'bold' }}>{arrow}</span>{' '}
{Math.abs(item.future_change_percent).toFixed(1)}%)
</div>
)
})()}
</td>
<td style={{ ...styles.td, fontSize: '0.85rem', ...errorCellStyleMiddle }}>{item.tax_rate || '—'}</td>
<td style={{ ...styles.td, ...errorCellStyleRight }}>{item.amount ? `£${item.amount.toFixed(2)}` : '—'}</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<input
type="checkbox"
checked={!item.is_non_stock}
onChange={(e) => {
updateLineItemMutation.mutate({
itemId: item.id,
data: { is_non_stock: !e.target.checked }
})
}}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
title={!item.is_non_stock ? 'Mark as non-stock item' : 'Mark as stock item'}
/>
</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<button
type="button"
onClick={() => toggleCostBreakdown(item)}
style={{
...styles.scaleBtn,
...(ingredientModalItem?.id === item.id ? styles.scaleBtnActive : {}),
color: getScaleIconColor(item)
}}
title={
item.ingredient_id
? `Mapped → ${item.ingredient_name || 'Ingredient'}`
: !item.pack_quantity
? 'No pack data - click to map ingredient'
: item.portions_per_unit === null
? 'Pack data extracted - click to map ingredient'
: 'Pack data complete - click to map ingredient'
}
>
<ScaleIcon />
</button>
</td>
<td style={{ ...styles.td, minWidth: '135px', whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{bulkEditMode ? (
<>
<button onClick={() => startEditLineItem(item)} style={styles.iconBtn} title="Edit line item">
</button>
<button
onClick={() => handleDeleteLineItem(item.id)}
style={{
...styles.iconBtn,
color: '#dc3545'
}}
title="Delete line item"
>
🗑
</button>
</>
) : (
<>
<button onClick={() => openSearchModal(item)} style={styles.iconBtn} title="Search similar items">
🔍
</button>
<button
onClick={() => item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_percent !== null && openPriceHistoryModal(item)}
style={{
...styles.iconBtn,
opacity: (item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_percent !== null) ? 1 : 0.5,
cursor: (item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_percent !== null) ? 'pointer' : 'not-allowed'
}}
title={
(item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_percent !== null)
? "View price history"
: "No price history available"
}
disabled={!(item.price_change_status && item.price_change_status !== 'no_history' && item.price_change_percent !== null)}
>
📊
</button>
</>
)}
</div>
</td>
</>
)}
</tr>
{expandedLineItem === item.id && bbox && (
<tr>
<td colSpan={11} style={styles.lineItemPreviewCell}>
{isPDF && pdfPages.length > 0 ? (
(() => {
// Get the correct page canvas (high-res)
const pageIndex = bbox.pageNumber - 1
const pageData = pdfPages[pageIndex]
if (!pageData) return null
// Calculate bounding box coordinates in high-res canvas pixels
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
// Small padding around OCR text so box doesn't cover the text
const boxPadding = 15 // 15px breathing room around detected text
const expandedBboxX = Math.max(0, bboxX - boxPadding)
const expandedBboxY = Math.max(0, bboxY - boxPadding)
const expandedBboxW = bboxW + (boxPadding * 2)
const expandedBboxH = bboxH + (boxPadding * 2)
// Crop tightly to the expanded box - minimal extra space
const cropPadding = 5 // Very tight crop so box fills container
const startX = Math.max(0, expandedBboxX - cropPadding)
const startY = Math.max(0, expandedBboxY - cropPadding)
const endX = Math.min(pageData.width, expandedBboxX + expandedBboxW + cropPadding)
const endY = Math.min(pageData.height, expandedBboxY + expandedBboxH + cropPadding)
const cropW = endX - startX
const cropH = endY - startY
// Calculate expanded bounding box position relative to cropped area
const relBboxX = expandedBboxX - startX
const relBboxY = expandedBboxY - startY
// Create a cropped canvas
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = cropW
croppedCanvas.height = cropH
const ctx = croppedCanvas.getContext('2d')
if (ctx) {
ctx.drawImage(
pageData.canvas,
startX, startY, cropW, cropH,
0, 0, cropW, cropH
)
}
return (
<div style={styles.lineItemPreviewContainer}>
<div style={{
position: 'relative',
display: 'block',
width: '100%',
}}>
<img
src={croppedCanvas.toDataURL()}
alt="Line item from invoice"
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{/* Bounding box overlay */}
<div
style={{
position: 'absolute',
left: `${(relBboxX / cropW) * 100}%`,
top: `${(relBboxY / cropH) * 100}%`,
width: `${(expandedBboxW / cropW) * 100}%`,
height: `${(expandedBboxH / cropH) * 100}%`,
border: '2px solid #ffc107',
borderRadius: '2px',
pointerEvents: 'none',
boxShadow: '0 0 8px rgba(255, 193, 7, 0.5)',
}}
/>
</div>
</div>
)
})()
) : imageUrl ? (
(() => {
// Expand bounding box for better visibility (percentage-based)
const expansionPercent = 2 // Expand by 2% in each direction
const expandedX = Math.max(0, bbox.x - expansionPercent)
const expandedY = Math.max(0, bbox.y - expansionPercent)
const expandedWidth = Math.min(100 - expandedX, bbox.width + (expansionPercent * 2))
const expandedHeight = Math.min(100 - expandedY, bbox.height + (expansionPercent * 2))
return (
<div style={styles.lineItemPreviewContainer}>
<div style={{
position: 'relative',
display: 'block',
width: '100%',
}}>
<img
src={`${imageUrl.split('?')[0]}?token=${encodeURIComponent(token || '')}`}
alt="Line item location"
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{/* Bounding box overlay */}
<div
style={{
position: 'absolute',
left: `${expandedX}%`,
top: `${expandedY}%`,
width: `${expandedWidth}%`,
height: `${expandedHeight}%`,
border: '2px solid #ffc107',
borderRadius: '2px',
pointerEvents: 'none',
boxShadow: '0 0 8px rgba(255, 193, 7, 0.5)',
}}
/>
</div>
</div>
)
})()
) : null}
</td>
</tr>
)}
</React.Fragment>
)
})}
{bulkEditMode && (
<tr>
<td colSpan={11} style={{ textAlign: 'center', padding: '1.5rem', background: '#f9fafb' }}>
<button
onClick={handleAddLineItem}
style={{
...styles.confirmBtn,
padding: '0.75rem 2rem',
fontSize: '0.95rem'
}}
>
+ Add Line Item
</button>
</td>
</tr>
)}
</tbody>
</table>
</div>
) : (
<p style={styles.noItems}>No line items extracted</p>
)}
{/* Line Items Total Validation */}
<LineItemsValidation
lineItems={lineItems || []}
invoiceTotal={parseFloat(total) || 0}
netTotal={netTotal ? parseFloat(netTotal) : null}
/>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div style={styles.modalOverlay} onClick={() => setShowDeleteModal(false)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Delete Invoice?</h3>
<div style={styles.modalInfo}>
<p><strong>Invoice #:</strong> {invoice.invoice_number || 'N/A'}</p>
<p><strong>Date:</strong> {invoice.invoice_date || 'N/A'}</p>
<p><strong>Total:</strong> {invoice.total ? `£${Number(invoice.total).toFixed(2)}` : 'N/A'}</p>
</div>
{invoice.duplicate_status && (
<div style={styles.modalDuplicateInfo}>
This invoice is marked as a {invoice.duplicate_status === 'firm_duplicate' ? 'duplicate' : 'possible duplicate'}.
Deleting it will resolve the duplicate warning.
</div>
)}
<div style={styles.modalActions}>
<button onClick={() => setShowDeleteModal(false)} style={styles.cancelBtn}>Cancel</button>
<button onClick={handleDelete} style={styles.confirmDeleteBtn}>Delete</button>
</div>
</div>
</div>
)}
{/* Dext Send Confirmation Modal */}
{showDextSendConfirm && (
<div style={styles.modalOverlay} onClick={() => setShowDextSendConfirm(false)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Send Invoice to Dext?</h3>
<p style={{ marginBottom: '1rem' }}>
This will email the invoice PDF to your configured Dext address.
</p>
{invoiceNotes && (
<p style={{ fontSize: '0.9rem', color: '#666' }}>
<strong>Note:</strong> Invoice notes will be included in the email.
</p>
)}
{lineItems && lineItems.some(item => item.is_non_stock) && (
<p style={{ fontSize: '0.9rem', color: '#666' }}>
<strong>Note:</strong> Non-stock items table will be included in the email.
</p>
)}
<div style={styles.modalActions}>
<button onClick={() => setShowDextSendConfirm(false)} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={async () => {
try {
const res = await fetch(`/kitchen/api/invoices/${id}/send-to-dext`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const result = await res.json()
alert(`Invoice sent to Dext successfully at ${new Date(result.sent_at).toLocaleString()}`)
setShowDextSendConfirm(false)
queryClient.invalidateQueries({ queryKey: ['invoice', id] })
} else {
const error = await res.json()
alert(`Failed to send: ${error.detail}`)
}
} catch (error) {
console.error('Failed to send to Dext:', error)
alert('Failed to send invoice to Dext')
}
}}
style={styles.confirmBtn}
>
Send to Dext
</button>
</div>
</div>
</div>
)}
{/* Duplicate Comparison Modal */}
{showDuplicateModal && duplicateInfo && (
<div style={styles.modalOverlay} onClick={() => setShowDuplicateModal(false)}>
<div style={styles.wideModal} onClick={(e) => e.stopPropagation()}>
<h3>Duplicate Comparison</h3>
<div style={styles.compareGrid}>
<div style={styles.compareCard}>
<h4>Current Invoice</h4>
<p><strong>Invoice #:</strong> {duplicateInfo.current_invoice.invoice_number || '—'}</p>
<p><strong>Date:</strong> {duplicateInfo.current_invoice.invoice_date || '—'}</p>
<p><strong>Total:</strong> {duplicateInfo.current_invoice.total ? `£${Number(duplicateInfo.current_invoice.total).toFixed(2)}` : '—'}</p>
<p><strong>Order #:</strong> {duplicateInfo.current_invoice.order_number || '—'}</p>
<p><strong>Type:</strong> {duplicateInfo.current_invoice.document_type || 'invoice'}</p>
</div>
{duplicateInfo.firm_duplicate && (
<div style={{ ...styles.compareCard, borderColor: '#dc3545' }}>
<h4 style={{ color: '#dc3545' }}>Exact Duplicate</h4>
<p><strong>Invoice #:</strong> {duplicateInfo.firm_duplicate.invoice_number || '—'}</p>
<p><strong>Date:</strong> {duplicateInfo.firm_duplicate.invoice_date || '—'}</p>
<p><strong>Total:</strong> {duplicateInfo.firm_duplicate.total ? `£${Number(duplicateInfo.firm_duplicate.total).toFixed(2)}` : '—'}</p>
<p><strong>Order #:</strong> {duplicateInfo.firm_duplicate.order_number || '—'}</p>
<p><strong>Type:</strong> {duplicateInfo.firm_duplicate.document_type || 'invoice'}</p>
<button
onClick={() => navigate(`/invoice/${duplicateInfo.firm_duplicate!.id}`)}
style={styles.viewBtn}
>
View This Invoice
</button>
</div>
)}
{duplicateInfo.possible_duplicates.map((dup) => (
<div key={dup.id} style={{ ...styles.compareCard, borderColor: '#ffc107' }}>
<h4 style={{ color: '#856404' }}>Possible Duplicate</h4>
<p><strong>Invoice #:</strong> {dup.invoice_number || '—'}</p>
<p><strong>Date:</strong> {dup.invoice_date || '—'}</p>
<p><strong>Total:</strong> {dup.total ? `£${Number(dup.total).toFixed(2)}` : '—'}</p>
<p><strong>Order #:</strong> {dup.order_number || '—'}</p>
<button
onClick={() => navigate(`/invoice/${dup.id}`)}
style={styles.viewBtn}
>
View This Invoice
</button>
</div>
))}
{duplicateInfo.related_documents.map((doc) => (
<div key={doc.id} style={{ ...styles.compareCard, borderColor: '#17a2b8' }}>
<h4 style={{ color: '#17a2b8' }}>Related {doc.document_type === 'delivery_note' ? 'Delivery Note' : 'Invoice'}</h4>
<p><strong>Invoice #:</strong> {doc.invoice_number || '—'}</p>
<p><strong>Date:</strong> {doc.invoice_date || '—'}</p>
<p><strong>Total:</strong> {doc.total ? `£${Number(doc.total).toFixed(2)}` : '—'}</p>
<p><strong>Order #:</strong> {doc.order_number || '—'}</p>
<button
onClick={() => navigate(`/invoice/${doc.id}`)}
style={styles.viewBtn}
>
View This Invoice
</button>
</div>
))}
</div>
<button onClick={() => setShowDuplicateModal(false)} style={styles.closeBtn}>Close</button>
</div>
</div>
)}
{/* Create Supplier Modal */}
{showCreateSupplierModal && (
<div style={styles.modalOverlay} onClick={() => setShowCreateSupplierModal(false)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Create New Supplier</h3>
<label style={styles.modalLabel}>
Supplier Name
<input
type="text"
value={newSupplierName}
onChange={(e) => setNewSupplierName(e.target.value)}
style={styles.input}
placeholder="e.g., Sysco, US Foods"
autoFocus
/>
</label>
<div style={styles.modalActions}>
<button onClick={() => setShowCreateSupplierModal(false)} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={handleCreateSupplier}
style={styles.saveBtn}
disabled={!newSupplierName.trim() || createSupplierMutation.isPending}
>
{createSupplierMutation.isPending ? 'Creating...' : 'Create & Select'}
</button>
</div>
</div>
</div>
)}
{/* Raw OCR Data Modal */}
{showRawOcrModal && (
<div style={styles.modalOverlay} onClick={() => setShowRawOcrModal(false)}>
<div style={styles.rawOcrModal} onClick={(e) => e.stopPropagation()}>
<h3>Raw Azure OCR Data</h3>
<p style={styles.rawOcrHint}>
This shows the raw data extracted by Azure. Use this to debug field extraction issues
or identify custom field names that need mapping.
</p>
{rawOcrData?.raw_json ? (
<div style={styles.rawOcrContent}>
<h4>Extracted Fields</h4>
{rawOcrData.raw_json.documents?.map((doc: any, docIdx: number) => (
<div key={docIdx} style={styles.rawOcrDoc}>
<h5>Document {docIdx + 1} (Confidence: {(doc.confidence * 100).toFixed(1)}%)</h5>
<table style={styles.rawOcrTable}>
<thead>
<tr>
<th style={styles.rawOcrTh}>Field</th>
<th style={styles.rawOcrTh}>Value</th>
<th style={styles.rawOcrTh}>Content</th>
</tr>
</thead>
<tbody>
{Object.entries(doc.fields || {}).map(([fieldName, field]: [string, any]) => (
<tr key={fieldName}>
<td style={styles.rawOcrTd}><strong>{fieldName}</strong></td>
<td style={styles.rawOcrTd}>
{typeof field?.value === 'object'
? JSON.stringify(field?.value, null, 2)
: String(field?.value ?? '—')}
</td>
<td style={styles.rawOcrTd}>{field?.content || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
) : (
<p style={{ color: '#999' }}>No raw OCR data available for this invoice.</p>
)}
<button onClick={() => setShowRawOcrModal(false)} style={styles.closeBtn}>Close</button>
</div>
</div>
)}
{/* Non-stock confirmation modal */}
{showNonStockConfirmModal && (
<div style={styles.modalOverlay} onClick={() => setShowNonStockConfirmModal(false)}>
<div style={{ ...styles.rawOcrModal, maxWidth: '480px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ marginTop: 0, marginBottom: '0.75rem' }}>Previously Non-Stock Items</h3>
<p style={{ fontSize: '0.9rem', color: '#555', marginBottom: '1rem', lineHeight: 1.5 }}>
The following item{nonStockConflictItems.length !== 1 ? 's have' : ' has'} been marked as <strong>non-stock</strong> on previous invoices from this supplier but {nonStockConflictItems.length !== 1 ? 'are' : 'is'} currently included in the stock cost for this invoice. Please confirm they have been checked.
</p>
<div style={{ marginBottom: '1.25rem', borderRadius: '6px', border: '1px solid #f0c36d', background: '#fffbf0', overflow: 'hidden' }}>
{nonStockConflictItems.map((item, idx) => (
<div
key={idx}
style={{
padding: '0.6rem 0.85rem',
borderBottom: idx < nonStockConflictItems.length - 1 ? '1px solid #f0c36d' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
}}
>
<span style={{ fontSize: '0.85rem', color: '#333' }}>
{item.description?.split('\n')[0] || '(no description)'}
</span>
{item.amount != null && (
<span style={{ fontSize: '0.85rem', color: '#856404', fontWeight: 600, whiteSpace: 'nowrap' }}>
£{item.amount.toFixed(2)}
</span>
)}
</div>
))}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '1.25rem' }}>
Confirming will include {nonStockConflictItems.length !== 1 ? 'these items' : 'this item'} in the stock cost. Mark {nonStockConflictItems.length !== 1 ? 'them' : 'it'} as non-stock first if that was not intended.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowNonStockConfirmModal(false)}
style={{ ...styles.closeBtn, margin: 0 }}
>
Go back
</button>
<button
onClick={async () => {
setShowNonStockConfirmModal(false)
await doConfirm()
}}
style={{
padding: '0.5rem 1.25rem',
background: '#e85d04',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.9rem',
}}
>
Confirm anyway
</button>
</div>
</div>
</div>
)}
{/* Date Picker Modal - select date from parsed dates */}
{showDatePickerModal && (
<div style={styles.modalOverlay} onClick={() => setShowDatePickerModal(false)}>
<div style={{ ...styles.rawOcrModal, maxWidth: '520px' }} onClick={(e) => e.stopPropagation()}>
<h3>Select Invoice Date</h3>
{/* OCR image snippet for the InvoiceDate field */}
{(() => {
const bbox = getFieldBoundingBox('InvoiceDate')
if (!bbox) return null
const isPdf = invoice?.image_path?.toLowerCase().endsWith('.pdf')
if (isPdf && pdfPages.length > 0) {
const pageData = pdfPages[bbox.pageNumber - 1]
if (!pageData) return null
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 pad = 20
const startX = Math.max(0, bboxX - pad * 3)
const startY = Math.max(0, bboxY - pad)
const endX = Math.min(pageData.width, bboxX + bboxW + pad * 3)
const endY = Math.min(pageData.height, bboxY + bboxH + pad)
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) {
ctx.drawImage(pageData.canvas, startX, startY, cropW, cropH, 0, 0, cropW, cropH)
// Draw highlight box
ctx.strokeStyle = '#e85d04'
ctx.lineWidth = 3
ctx.strokeRect(bboxX - startX - 4, bboxY - startY - 4, bboxW + 8, bboxH + 8)
}
return (
<div style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ fontSize: '0.75rem', color: '#666', padding: '4px 8px', background: '#f8f9fa', borderBottom: '1px solid #ddd' }}>
Date field from document
</div>
<img src={croppedCanvas.toDataURL()} alt="Date field from invoice" style={{ width: '100%', height: 'auto', display: 'block' }} />
</div>
)
}
return null
})()}
{/* Suggested fixes when current date is in the future */}
{invoiceDate && getDateWarning(invoiceDate, 'PENDING') === 'future' && (() => {
const today = new Date(); today.setHours(0, 0, 0, 0)
const swapped = getSwappedDate(invoiceDate)
const swappedIsValid = swapped && new Date(swapped) <= today
const yearFix = getYearCorrectedDate(invoiceDate)
if (!swappedIsValid && !yearFix) return null
const suggestions: { date: string; label: string; sublabel: string }[] = []
if (yearFix) {
suggestions.push({
date: yearFix.date,
label: new Date(yearFix.date).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }),
sublabel: `Year corrected from ${invoiceDate.split('-')[0]}${yearFix.year}`,
})
}
if (swappedIsValid && swapped) {
suggestions.push({
date: swapped,
label: new Date(swapped).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }),
sublabel: `Day and month swapped from ${new Date(invoiceDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`,
})
}
return (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: '#e85d04', marginBottom: '0.5rem' }}>
Suggested fix{suggestions.length > 1 ? 'es' : ''} date is in the future:
</div>
{suggestions.map((s, i) => (
<div
key={i}
onClick={() => handleSelectParsedDate(s.date)}
style={{
padding: '0.75rem 1rem',
border: '2px solid #e85d04',
borderRadius: '4px',
cursor: 'pointer',
background: '#fff8f5',
transition: 'all 0.2s',
marginBottom: i < suggestions.length - 1 ? '0.5rem' : 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#fff0e8' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff8f5' }}
>
<div style={{ fontWeight: '600', fontSize: '1.1rem', marginBottom: '0.25rem' }}>{s.label}</div>
<div style={{ fontSize: '0.8rem', color: '#888' }}>{s.sublabel}</div>
</div>
))}
{parsedDates.length > 0 && (
<div style={{ borderTop: '1px solid #eee', marginTop: '1rem', paddingTop: '0.75rem', fontSize: '0.8rem', color: '#666', marginBottom: '0.5rem' }}>
Or choose a date found in the document:
</div>
)}
</div>
)
})()}
{!invoiceDate && (
<p style={{ color: '#666', marginBottom: '1rem', fontSize: '0.9rem' }}>
Dates found in the document. Click to select.
</p>
)}
{invoiceDate && getDateWarning(invoiceDate, 'PENDING') !== 'future' && (
<p style={{ color: '#666', marginBottom: '1rem', fontSize: '0.9rem' }}>
Dates found in the document. Click to select.
</p>
)}
{parsedDatesLoading ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>Searching for dates...</p>
) : parsedDates.length === 0 ? (
invoiceDate && getDateWarning(invoiceDate, 'PENDING') === 'future' ? null : (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No dates found in document text.</p>
)
) : (
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{parsedDates.filter(dateInfo => {
// Suppress dates already shown as swap/year-fix suggestions
if (!invoiceDate || getDateWarning(invoiceDate, 'PENDING') !== 'future') return true
const today = new Date(); today.setHours(0, 0, 0, 0)
const swapped = getSwappedDate(invoiceDate)
if (swapped && new Date(swapped) <= today && dateInfo.date === swapped) return false
const yearFix = getYearCorrectedDate(invoiceDate)
if (yearFix && dateInfo.date === yearFix.date) return false
return true
}).map((dateInfo, idx) => {
const snippetUrl = dateInfo.bbox ? cropBboxToDataUrl(dateInfo.bbox, { padX: 80, padY: 25, strokeColor: '#e85d04' }) : null
return (
<div
key={idx}
onClick={() => handleSelectParsedDate(dateInfo.date)}
style={{
marginBottom: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.2s',
background: '#fff',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f7ff'
e.currentTarget.style.borderColor = '#007bff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fff'
e.currentTarget.style.borderColor = '#ddd'
}}
>
{snippetUrl && (
<img
src={snippetUrl}
alt="Date location in document"
style={{ width: '100%', height: 'auto', display: 'block', borderBottom: '1px solid #eee' }}
/>
)}
<div style={{ padding: '0.75rem 1rem' }}>
<div style={{ fontWeight: '600', fontSize: '1.1rem', marginBottom: '0.25rem' }}>
{new Date(dateInfo.date).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
<div style={{ fontSize: '0.8rem', color: '#666' }}>
Original: {dateInfo.original}
</div>
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.25rem', fontStyle: 'italic' }}>
...{dateInfo.context}...
</div>
</div>
</div>
)
})}
</div>
)}
<button onClick={() => setShowDatePickerModal(false)} style={styles.closeBtn}>Cancel</button>
</div>
</div>
)}
{/* Invoice Number Search Modal */}
{showInvoiceNumberModal && (
<div style={styles.modalOverlay} onClick={() => setShowInvoiceNumberModal(false)}>
<div style={{ ...styles.rawOcrModal, maxWidth: '520px' }} onClick={(e) => e.stopPropagation()}>
<h3>Find Invoice Number</h3>
{/* OCR image snippet for InvoiceId field if available */}
{(() => {
const bbox = getFieldBoundingBox('InvoiceId')
if (!bbox) return null
const isPdf = invoice?.image_path?.toLowerCase().endsWith('.pdf')
if (isPdf && pdfPages.length > 0) {
const pageData = pdfPages[bbox.pageNumber - 1]
if (!pageData) return null
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 pad = 20
const startX = Math.max(0, bboxX - pad * 3)
const startY = Math.max(0, bboxY - pad)
const endX = Math.min(pageData.width, bboxX + bboxW + pad * 3)
const endY = Math.min(pageData.height, bboxY + bboxH + pad)
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) {
ctx.drawImage(pageData.canvas, startX, startY, cropW, cropH, 0, 0, cropW, cropH)
ctx.strokeStyle = '#007bff'
ctx.lineWidth = 3
ctx.strokeRect(bboxX - startX - 4, bboxY - startY - 4, bboxW + 8, bboxH + 8)
}
return (
<div style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ fontSize: '0.75rem', color: '#666', padding: '4px 8px', background: '#f8f9fa', borderBottom: '1px solid #ddd' }}>
Invoice number field from document
</div>
<img src={croppedCanvas.toDataURL()} alt="Invoice number field" style={{ width: '100%', height: 'auto', display: 'block' }} />
</div>
)
}
return null
})()}
{/* Supplier format examples */}
{invoiceNumberExamples.length > 0 && (
<div style={{ marginBottom: '1rem', padding: '0.5rem 0.75rem', background: '#f8f9fa', borderRadius: '4px', fontSize: '0.8rem', color: '#555' }}>
<strong>Format from this supplier:</strong>{' '}
{invoiceNumberExamples.slice(0, 4).join(', ')}
</div>
)}
<p style={{ color: '#666', marginBottom: '1rem', fontSize: '0.9rem' }}>
{invoiceNumberLoading
? 'Searching document...'
: invoiceNumberCandidates.length === 0
? 'No candidates found in document text.'
: 'Candidates found in the document. Click to use.'}
</p>
{invoiceNumberLoading ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>Searching for invoice number...</p>
) : invoiceNumberCandidates.length === 0 ? null : (
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{invoiceNumberCandidates.map((c, idx) => {
const snippetUrl = c.bbox ? cropBboxToDataUrl(c.bbox, { padX: 80, padY: 25, strokeColor: '#007bff' }) : null
return (
<div
key={idx}
onClick={() => handleSelectInvoiceNumber(c.value)}
style={{
marginBottom: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
background: '#fff',
transition: 'all 0.2s',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f7ff'
e.currentTarget.style.borderColor = '#007bff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fff'
e.currentTarget.style.borderColor = '#ddd'
}}
>
{snippetUrl && (
<img
src={snippetUrl}
alt="Candidate location in document"
style={{ width: '100%', height: 'auto', display: 'block', borderBottom: '1px solid #eee' }}
/>
)}
<div style={{ padding: '0.75rem 1rem' }}>
<div style={{ fontWeight: '600', fontSize: '1.05rem', marginBottom: '0.25rem', fontFamily: 'monospace' }}>
{c.value}
</div>
<div style={{ fontSize: '0.75rem', color: '#999', marginBottom: '0.2rem' }}>
{c.source === 'ocr_field' ? '📋 Azure OCR field'
: c.source === 'supplier_pattern' ? '🔗 Matches supplier format'
: '🔤 Found near reference keyword'}
</div>
<div style={{ fontSize: '0.75rem', color: '#888', fontStyle: 'italic' }}>
...{c.context}...
</div>
</div>
</div>
)
})}
</div>
)}
<button onClick={() => setShowInvoiceNumberModal(false)} style={styles.closeBtn}>Cancel</button>
</div>
</div>
)}
{/* Line Item Search Modal */}
{showSearchModal && searchingLineItem && (
<div style={styles.modalOverlay} onClick={() => setShowSearchModal(false)}>
<div style={styles.searchModal} onClick={(e) => e.stopPropagation()}>
<h3>Compare Similar Items</h3>
{/* Current Item - shown at top for comparison */}
<div style={styles.currentItemSection}>
<h4 style={styles.currentItemHeader}>Current Item ({suppliers?.find(s => s.id === invoice?.supplier_id)?.name || 'Unknown Supplier'})</h4>
<table style={styles.searchResultsTable}>
<thead>
<tr>
<th style={styles.searchTh}>Description</th>
<th style={styles.searchThRight}>Unit Price</th>
<th style={styles.searchTh}>Unit</th>
<th style={styles.searchTh}>Pack Info</th>
<th style={styles.searchTh}>Invoice Date</th>
</tr>
</thead>
<tbody>
<tr style={styles.currentItemRow}>
<td style={styles.searchTd}>{searchingLineItem.description}</td>
<td style={styles.searchTdRight}>
{searchingLineItem.unit_price ? `£${searchingLineItem.unit_price.toFixed(2)}` : '—'}
</td>
<td style={styles.searchTd}>{searchingLineItem.unit || '—'}</td>
<td style={styles.searchTd}>
{searchingLineItem.pack_quantity && searchingLineItem.unit_size && searchingLineItem.unit_size_type
? `${searchingLineItem.pack_quantity}x${searchingLineItem.unit_size}${searchingLineItem.unit_size_type}`
: searchingLineItem.pack_quantity
? `${searchingLineItem.pack_quantity} pack`
: '—'}
</td>
<td style={styles.searchTd}>{invoice?.invoice_date || '—'}</td>
</tr>
</tbody>
</table>
</div>
<div style={styles.searchInputRow}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && performSearch(searchQuery)}
style={styles.searchInput}
placeholder="Enter search keywords..."
autoFocus
/>
<button
onClick={() => performSearch(searchQuery)}
style={styles.searchButton}
disabled={searchLoading}
>
{searchLoading ? 'Searching...' : 'Search'}
</button>
</div>
{searchResults && (
<div style={styles.searchResultsContainer}>
<p style={styles.resultsInfo}>
Found {searchResults.total_matches} matches
{searchResults.extracted_keywords && (
<span style={{ color: '#666' }}> (searched: "{searchResults.extracted_keywords}")</span>
)}
</p>
{searchResults.results.length === 0 ? (
<p style={{ color: '#999', fontStyle: 'italic' }}>No matching items found.</p>
) : (
searchResults.results.map((group) => (
<div key={group.supplier_id || 'unknown'} style={styles.supplierGroup}>
<h4 style={styles.supplierHeader}>{group.supplier_name}</h4>
<table style={styles.searchResultsTable}>
<thead>
<tr>
<th style={styles.searchTh}>Description</th>
<th style={styles.searchThRight}>Unit Price</th>
<th style={styles.searchTh}>Unit</th>
<th style={styles.searchTh}>Pack Info</th>
<th style={styles.searchTh}>Last Invoice</th>
<th style={styles.searchTh}></th>
</tr>
</thead>
<tbody>
{group.items.map((item, idx) => (
<tr key={idx}>
<td style={styles.searchTd}>{item.description}</td>
<td style={styles.searchTdRight}>
{item.unit_price != null ? `£${item.unit_price.toFixed(2)}` : '—'}
</td>
<td style={styles.searchTd}>{item.unit || '—'}</td>
<td style={styles.searchTd}>{item.pack_info || '—'}</td>
<td style={styles.searchTd}>{item.last_invoice_date || '—'}</td>
<td style={styles.searchTd}>
<a
href={`/kitchen/invoice/${item.invoice_id}`}
target="_blank"
rel="noopener noreferrer"
style={styles.invoiceLink}
title="Open invoice in new tab"
>
View
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
</div>
)}
<button onClick={() => setShowSearchModal(false)} style={styles.closeBtn}>Close</button>
</div>
</div>
)}
{/* Bulk Stock Modal */}
{showBulkStockModal && (
<div style={styles.modalOverlay} onClick={() => setShowBulkStockModal(false)}>
<div style={{ ...styles.modal, maxWidth: '400px', textAlign: 'center' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ marginBottom: '1rem' }}>Bulk Stock Update</h3>
<p style={{ marginBottom: '1.5rem', color: '#666' }}>
Mark all line items on this invoice as:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button
onClick={() => handleBulkStockUpdate(true)}
style={{
...styles.saveBtn,
padding: '1rem',
background: '#28a745',
fontSize: '0.95rem'
}}
>
Mark All as Stock
</button>
<button
onClick={() => handleBulkStockUpdate(false)}
style={{
...styles.saveBtn,
padding: '1rem',
background: '#dc3545',
fontSize: '0.95rem'
}}
>
Mark All as Non-Stock
</button>
<button
onClick={() => setShowBulkStockModal(false)}
style={{
...styles.backBtn,
padding: '0.75rem',
marginTop: '0.5rem'
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Price 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}
currentPrice={historyModal.currentPrice}
sourceInvoiceId={historyModal.sourceInvoiceId}
sourceLineItemId={historyModal.sourceLineItemId}
onAcknowledge={() => {
refetchLineItems()
}}
/>
)}
{/* Create Dispute Modal */}
{showDisputeModal && invoice && (
<CreateDisputeModal
invoiceId={invoice.id}
invoiceNumber={invoice.invoice_number}
supplierId={invoice.supplier_id || null}
supplierName={invoice.supplier_name || null}
lineItems={lineItems || []}
onClose={() => setShowDisputeModal(false)}
onSuccess={() => {
refetchInvoice() // Refresh invoice to update dispute count
}}
/>
)}
{/* Link Credit Note to Dispute Modal */}
{showLinkDisputeModal && invoice && invoice.supplier_id && (
<LinkDisputeModal
supplierId={invoice.supplier_id}
supplierName={invoice.supplier_name || null}
creditNoteInvoiceId={invoice.id}
creditNoteNumber={invoice.invoice_number}
creditNoteAmount={invoice.total}
onClose={() => setShowLinkDisputeModal(false)}
onSuccess={() => {
setShowLinkDisputeModal(false)
refetchInvoice() // Refresh to show "Resolved Dispute" button
}}
/>
)}
{/* View Dispute Detail Modal */}
{viewingDisputeId && (
<DisputeDetailModal
disputeId={viewingDisputeId}
onClose={() => setViewingDisputeId(null)}
onUpdate={() => {
refetchInvoice() // Refresh invoice to update dispute data
}}
/>
)}
{/* Description Swap Modal */}
{descSwapItem && (
<div style={styles.modalOverlay} onClick={() => setDescSwapItem(null)}>
<div style={styles.descSwapModal} onClick={(e) => e.stopPropagation()}>
<h3 style={{ marginBottom: '0.5rem' }}>Choose Description</h3>
<p style={styles.modalHint}>
OCR extracted two different descriptions. Select the correct one:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1rem' }}>
{/* Current Description */}
<div
style={styles.descriptionOption}
onClick={() => {
// Keep current - just close
setDescSwapItem(null)
}}
>
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#666' }}>
Current (Value)
</div>
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>
{descSwapItem.description || '(empty)'}
</div>
</div>
{/* Alternative Description */}
<div
style={{ ...styles.descriptionOption, borderColor: '#e94560' }}
onClick={async () => {
// Update to alternative
await updateLineItemMutation.mutateAsync({
itemId: descSwapItem.id,
data: {
description: descSwapItem.description_alt,
description_alt: null // Clear alt after swap
}
})
setDescSwapItem(null)
}}
>
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#e94560' }}>
Alternative (Content) Select
</div>
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>
{descSwapItem.description_alt || '(empty)'}
</div>
</div>
</div>
<button
onClick={() => setDescSwapItem(null)}
style={{ ...styles.backBtn, marginTop: '1rem', padding: '0.75rem' }}
>
Cancel
</button>
</div>
</div>
)}
{/* PO View/Edit Modal */}
<PurchaseOrderModal
isOpen={viewingPoId !== null}
onClose={() => setViewingPoId(null)}
onSaved={() => refetchPoMatch()}
poId={viewingPoId}
/>
{/* Ingredient Mapping Modal */}
{ingredientModalItem && (
<div style={styles.modalOverlay} onClick={closeIngredientModal}>
<div style={{ ...styles.wideModal, maxWidth: '650px' }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 style={{ margin: 0 }}>Ingredient Mapping</h3>
<button onClick={closeIngredientModal} style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer', color: '#666' }}>&times;</button>
</div>
{/* Line item context */}
<div style={{ padding: '0.75rem', background: '#f8f9fa', borderRadius: '6px', marginBottom: '1rem', fontSize: '0.9rem' }}>
<strong>{ingredientModalItem.description || ingredientModalItem.product_code || 'Unknown item'}</strong>
{ingredientModalItem.unit_price && <span style={{ marginLeft: '1rem', color: '#666' }}>£{ingredientModalItem.unit_price.toFixed(2)}</span>}
{ingredientModalItem.raw_content && (
<div style={{ fontSize: '0.8rem', color: '#999', marginTop: '0.25rem' }}>Raw: {ingredientModalItem.raw_content.substring(0, 80)}</div>
)}
</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(''); setIngredientConversionDisplay(''); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#666', fontSize: '1.1rem' }}
>&times;</button>
</div>
) : (
<>
<div style={{ position: 'relative' }}>
<input
type="text"
value={ingredientSearch}
onChange={(e) => {
setIngredientSearch(e.target.value)
searchIngredients(e.target.value)
}}
placeholder="Search ingredients..."
style={{ ...styles.input, width: '100%', boxSizing: 'border-box' }}
/>
{ingredientSearchLoading && <span style={{ position: 'absolute', right: '10px', top: '10px', color: '#999' }}>...</span>}
</div>
{/* Suggestions dropdown */}
{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>
)}
{/* LLM FEATURE — AI Match button + results — see LLM-MANIFEST.md for removal instructions */}
{settings?.llm_enabled && settings?.anthropic_api_key_set && ingredientModalItem?.description && !selectedIngredientId && (
<div style={{ marginTop: '0.5rem' }}>
<button
onClick={() => handleAiMatch(ingredientModalItem!.description!)}
disabled={aiMatchLoading}
style={{ padding: '0.4rem 0.75rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiMatchLoading ? 'wait' : 'pointer', fontSize: '0.85rem', opacity: aiMatchLoading ? 0.7 : 1 }}
>
{aiMatchLoading ? 'Matching...' : '\u2728 AI Match'}
</button>
{aiMatchResults.length > 0 && (
<div style={{ border: '1px solid #d4c5f9', borderRadius: '6px', marginTop: '0.35rem', maxHeight: '160px', overflowY: 'auto', background: '#f8f5ff' }}>
{aiMatchResults.map((m) => (
<div
key={m.id}
onClick={() => {
const found = ingredientSuggestions.find(s => s.id === m.id)
if (found) selectIngredient(found)
else { setSelectedIngredientId(m.id); setSelectedIngredientName(m.name); setSelectedIngredientUnit(''); }
setAiMatchResults([])
}}
style={{ padding: '0.4rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #e8e0f7', fontSize: '0.85rem', display: 'flex', justifyContent: 'space-between' }}
onMouseOver={(e) => (e.currentTarget.style.background = '#ede5ff')}
onMouseOut={(e) => (e.currentTarget.style.background = '#f8f5ff')}
>
<span>{'\u2728'} {m.name}</span>
<span style={{ color: '#7952b3', fontSize: '0.8rem' }}>{(m.confidence * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
</div>
)}
{/* Create new ingredient */}
<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>
{/* Invoice → Ingredient conversion */}
<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' }}>
{ingredientModalItem?.description}{ingredientModalItem?.unit_price ? ` @ £${ingredientModalItem.unit_price.toFixed(2)}` : ''}
{ingredientModalItem?.quantity && ingredientModalItem.quantity > 1 ? ` × ${ingredientModalItem.quantity}` : ''}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'flex-end' }}>
<div style={styles.costBreakdownField}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>
Contains
{/* LLM FEATURE */}
{aiPackSizeLoading && <span style={{ marginLeft: '4px', fontSize: '0.7rem', color: '#7952b3' }}>analysing...</span>}
{aiPackSizeSource === 'ai' && <span style={{ marginLeft: '4px', fontSize: '0.7rem', color: '#7952b3' }} title="Deduced by AI from product knowledge">{'\u2728'}</span>}
{aiPackSizeSource === 'regex' && <span style={{ marginLeft: '4px', fontSize: '0.7rem', color: '#28a745' }} title="Parsed from description">auto</span>}
{aiPackSizeSource === 'unit_field' && <span style={{ marginLeft: '4px', fontSize: '0.7rem', color: '#28a745' }} title="Inferred from invoice unit field">auto</span>}
</label>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="number" step="0.1"
value={costBreakdownEdits.unit_size || ''}
onChange={(e) => { const v = { ...costBreakdownEdits, unit_size: parseFloat(e.target.value) || null }; setCostBreakdownEdits(v); updateConversionDisplay(undefined, v) }}
onFocus={(e) => e.target.select()}
style={{ ...styles.costBreakdownInput, width: '70px' }}
placeholder="e.g., 2"
/>
<select
value={costBreakdownEdits.unit_size_type || selectedIngredientUnit || ''}
onChange={(e) => { const v = { ...costBreakdownEdits, unit_size_type: e.target.value || null }; setCostBreakdownEdits(v); updateConversionDisplay(undefined, v) }}
style={{ ...styles.costBreakdownInput, width: '60px' }}
>
<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 && costBreakdownEdits.unit_size_type && costBreakdownEdits.unit_size_type !== selectedIngredientUnit && (
<span style={{ fontSize: '0.75rem', color: '#999' }}> {selectedIngredientUnit}</span>
)}
</div>
</div>
<div style={styles.costBreakdownField}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>Pack of</label>
<input
type="number"
value={costBreakdownEdits.pack_quantity || ''}
onChange={(e) => { const v = { ...costBreakdownEdits, pack_quantity: parseInt(e.target.value) || null }; setCostBreakdownEdits(v); updateConversionDisplay(undefined, v) }}
onFocus={(e) => e.target.select()}
style={{ ...styles.costBreakdownInput, width: '50px' }}
placeholder="1"
/>
</div>
<div style={styles.costBreakdownField}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>Line Price</label>
<span style={{ ...styles.costBreakdownValue, fontSize: '0.9rem' }}>
{costBreakdownEdits.unit_price ? `£${costBreakdownEdits.unit_price.toFixed(2)}` : '--'}
</span>
</div>
</div>
{ingredientConversionDisplay && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.9rem', color: '#2e7d32', fontWeight: '500' }}>
{ingredientConversionDisplay}
</div>
)}
</div>
{/* Legacy portioning (collapsible) */}
<div style={{ marginBottom: '1rem' }}>
<button
onClick={() => setShowLegacyPortioning(!showLegacyPortioning)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.85rem', color: '#666', padding: 0 }}
>
{showLegacyPortioning ? '▾' : '▸'} Legacy Portioning
</button>
{showLegacyPortioning && (
<div style={{ marginTop: '0.5rem', padding: '0.75rem', background: '#f8f9fa', borderRadius: '6px', border: '1px solid #e0e0e0' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<div style={styles.costBreakdownField}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>Portions/Unit</label>
<input
type="number"
value={costBreakdownEdits.portions_per_unit ?? ''}
onChange={(e) => setCostBreakdownEdits({ ...costBreakdownEdits, portions_per_unit: e.target.value ? parseInt(e.target.value) : null })}
style={styles.costBreakdownInput} min="1" placeholder="--"
/>
</div>
<div style={{ ...styles.costBreakdownField, minWidth: '150px' }}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>Portion Desc</label>
<input
type="text" value={portionDescription}
onChange={(e) => setPortionDescription(e.target.value)}
style={{ ...styles.costBreakdownInput, width: '100%' }}
placeholder="e.g., 250ml glass"
/>
</div>
<div style={styles.costBreakdownField}>
<label style={{ fontSize: '0.8rem', color: '#666' }}>Cost/Portion</label>
<span style={{ ...styles.costBreakdownValue, color: '#28a745', fontWeight: '600', fontSize: '0.9rem' }}>
{costBreakdownEdits.pack_quantity && costBreakdownEdits.unit_price && costBreakdownEdits.portions_per_unit
? `£${(costBreakdownEdits.unit_price / (costBreakdownEdits.pack_quantity * costBreakdownEdits.portions_per_unit)).toFixed(4)}`
: '--'}
</span>
</div>
</div>
</div>
)}
</div>
{/* Save as default */}
{(ingredientModalItem.product_code || ingredientModalItem.description) && (
<>
{definitionLoading ? (
<div style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.5rem' }}>Loading saved default...</div>
) : currentDefinition ? (
<div style={{ ...styles.savedByInfo, marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.85rem' }}>
Definition by <strong>{currentDefinition.saved_by_username || 'Unknown'}</strong>
{currentDefinition.updated_at && <> on {new Date(currentDefinition.updated_at).toLocaleDateString()}</>}
</span>
</div>
) : null}
{!selectedIngredientId && (
<div style={{ ...styles.saveAsDefaultRow, marginBottom: '1rem' }}>
<label style={styles.saveAsDefaultLabel}>
<input type="checkbox" checked={saveAsDefault} onChange={(e) => setSaveAsDefault(e.target.checked)} style={{ marginRight: '0.5rem' }} />
{currentDefinition ? 'Update saved default' : `Save as default for "${ingredientModalItem.product_code || ingredientModalItem.description?.substring(0, 30)}"`}
</label>
</div>
)}
</>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button onClick={closeIngredientModal} style={{ ...styles.smallBtnCancel, padding: '0.5rem 1.25rem' }}>Cancel</button>
<button onClick={() => saveCostBreakdown(ingredientModalItem.id)} style={{ ...styles.smallBtn, padding: '0.5rem 1.25rem' }}>
{selectedIngredientId ? 'Save & Map Ingredient' : (saveAsDefault ? 'Save & Update Default' : 'Save')}
</button>
</div>
</div>
</div>
)}
<IngredientModal
open={showCreateIngredient}
onClose={() => setShowCreateIngredient(false)}
onSaved={handleIngredientCreated}
prePopulateName={ingredientSearch || ingredientModalItem?.description?.split('\n')[0] || ''}
preSelectLineItem={ingredientModalItem ? {
product_code: ingredientModalItem.product_code,
description: ingredientModalItem.description?.split('\n')[0] || '',
supplier_id: invoice?.supplier_id || null,
supplier_name: invoice?.supplier_name || null,
unit: ingredientModalItem.unit,
most_recent_price: ingredientModalItem.unit_price,
total_quantity: ingredientModalItem.quantity,
occurrence_count: 1,
most_recent_invoice_id: Number(id),
most_recent_line_number: ingredientModalItem.line_number,
most_recent_pack_quantity: ingredientModalItem.pack_quantity,
most_recent_unit_size: ingredientModalItem.unit_size,
most_recent_unit_size_type: ingredientModalItem.unit_size_type,
ingredient_id: ingredientModalItem.ingredient_id || null,
ingredient_name: ingredientModalItem.ingredient_name || null,
} as LineItemResult : null}
/>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: { padding: '2rem', textAlign: 'center', color: '#666' },
error: { padding: '2rem', textAlign: 'center', color: '#c00' },
pageContainer: { display: 'flex', flexDirection: 'column', gap: '1.5rem' },
topRow: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' },
imageSection: { background: 'white', padding: '1.5rem', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', overflow: 'hidden', minWidth: 0 },
imageZoomControls: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '1rem', justifyContent: 'center' },
zoomBtn: { padding: '0.5rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '1rem', fontWeight: 'bold', minWidth: '36px' },
zoomLabel: { fontSize: '0.9rem', color: '#666', minWidth: '50px', textAlign: 'center' },
zoomResetBtnSmall: { padding: '0.4rem 0.75rem', background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem', fontWeight: '500' },
imageScrollContainer: { marginTop: '0.5rem', maxHeight: '70vh', maxWidth: '100%', overflow: 'auto', background: '#f5f5f5', borderRadius: '8px', padding: '8px' },
imageContainer: { position: 'relative' },
image: { width: '100%', borderRadius: '8px', display: 'block' },
pdfScrollContainer: { marginTop: '1rem', maxHeight: '70vh', overflow: 'auto', background: '#e5e5e5', borderRadius: '8px', padding: '16px' },
pdfPageWrapper: { marginBottom: '16px' },
pdfPageContainer: { position: 'relative', margin: '0 auto', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' },
pdfPageImage: { width: '100%', height: '100%', display: 'block' },
pageLabel: { textAlign: 'center', fontSize: '0.8rem', color: '#666', marginTop: '4px' },
pdfCanvas: { width: '100%', borderRadius: '8px', display: 'block' },
highlightOverlay: { position: 'absolute', background: 'rgba(255, 200, 0, 0.3)', borderRadius: '2px', cursor: 'pointer', boxShadow: '0 0 8px rgba(255, 200, 0, 0.6)', animation: 'pulse 1.5s infinite' },
imagePlaceholder: { width: '100%', height: '300px', background: '#f5f5f5', borderRadius: '8px', marginTop: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' },
pdfViewer: { width: '100%', height: '500px', borderRadius: '8px', border: 'none' },
openPdfLink: { display: 'block', marginTop: '0.5rem', textAlign: 'center', color: '#0066cc', fontSize: '0.85rem' },
zoomResetBtn: { display: 'block', margin: '0.5rem auto', padding: '0.4rem 1rem', background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem', fontWeight: '500' },
confidenceBadge: { marginTop: '1rem', padding: '0.5rem 1rem', background: '#f0f0f0', borderRadius: '20px', textAlign: 'center', fontSize: '0.9rem', color: '#666' },
formSection: { background: 'white', padding: '1.5rem', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' },
duplicateWarning: { padding: '1rem', borderRadius: '8px', marginBottom: '1rem', cursor: 'pointer', borderWidth: '1px', borderStyle: 'solid', fontWeight: '500' },
form: { display: 'flex', flexDirection: 'column', gap: '0.75rem' },
row: { display: 'flex', gap: '1rem' },
label: { display: 'flex', flexDirection: 'column', gap: '0.25rem', color: '#333', fontWeight: '500', fontSize: '0.9rem' },
input: { padding: '0.5rem', borderRadius: '6px', border: '1px solid #ddd', fontSize: '0.95rem', flex: 1 },
inputWithEye: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
eyeBtn: { padding: '0.4rem 0.5rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem', lineHeight: 1, flexShrink: 0 },
eyeBtnActive: { background: '#fff3cd', border: '1px solid #ffc107' },
extractedHint: { fontSize: '0.8rem', color: '#666', fontWeight: 'normal', fontStyle: 'italic' },
extractedHintRow: { display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '0.25rem', flexWrap: 'wrap' },
fuzzyMatchWarning: { fontSize: '0.8rem', color: '#856404', background: '#fff3cd', padding: '0.35rem 0.5rem', borderRadius: '4px', marginTop: '0.25rem', border: '1px solid #ffc107', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem', flexWrap: 'wrap' },
addAliasBtn: { padding: '0.2rem 0.5rem', background: '#28a745', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: '500', whiteSpace: 'nowrap' },
supplierRow: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
createSupplierBtn: { padding: '0.5rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem', whiteSpace: 'nowrap' },
createFromExtractedBtn: { padding: '0.25rem 0.5rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: 'normal' },
modalLabel: { display: 'flex', flexDirection: 'column', gap: '0.5rem', color: '#333', fontWeight: '500', marginTop: '1rem' },
lineItemsSection: { background: 'white', padding: '1.5rem', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' },
lineItemsTableContainer: { maxHeight: '60vh', overflowY: 'auto', overflowX: 'auto', border: '1px solid #eee', borderRadius: '8px', position: 'relative' } as React.CSSProperties,
stickyThead: { position: 'sticky', top: 0, zIndex: 2, borderBottom: '2px solid #ddd' } as React.CSSProperties,
stickyPageIndicatorCell: { background: '#e9ecef', padding: '0.3rem 1rem', textAlign: 'center', fontSize: '0.85rem', fontWeight: '500', color: '#495057' } as React.CSSProperties,
lineItemsTable: { width: '100%', borderCollapse: 'collapse', fontSize: '0.95rem' },
th: { textAlign: 'left', padding: '0.75rem 0.5rem', fontWeight: '600', background: '#f8f9fa' } as React.CSSProperties,
pageBreakRow: { background: '#f8f9fa' },
pageBreakCell: { textAlign: 'center', padding: '0.5rem', color: '#6c757d', fontSize: '0.85rem', fontStyle: 'italic', borderBottom: '1px dashed #dee2e6', borderTop: '1px dashed #dee2e6' } as React.CSSProperties,
td: { padding: '0.75rem 0.5rem', borderBottom: '1px solid #eee', verticalAlign: 'middle' },
tableInput: { padding: '0.35rem', borderRadius: '4px', border: '1px solid #ddd', fontSize: '0.9rem', width: '100%' },
smallBtn: { padding: '0.35rem 0.75rem', background: '#5cb85c', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', marginRight: '0.25rem', fontSize: '0.8rem' },
smallBtnCancel: { padding: '0.35rem 0.75rem', background: '#999', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' },
editBtn: { padding: '0.35rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' },
iconBtn: { padding: '0.35rem 0.5rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '32px', height: '32px' },
noItems: { color: '#999', fontStyle: 'italic', marginTop: '0.5rem' },
status: { marginTop: '1rem', padding: '0.75rem', background: '#f5f5f5', borderRadius: '6px', color: '#666', fontSize: '0.9rem' },
docTypeBadge: { marginLeft: '1rem', padding: '0.25rem 0.5rem', background: '#17a2b8', color: 'white', borderRadius: '4px', fontSize: '0.75rem' },
actions: { display: 'flex', gap: '0.75rem', marginTop: '1rem' },
saveBtn: { flex: 1, padding: '1.25rem', background: '#1a1a2e', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem' },
confirmBtn: { flex: 1, padding: '1.25rem', background: '#5cb85c', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem' },
secondaryActions: { display: 'flex', gap: '0.75rem', marginTop: '0.75rem' },
deleteBtn: { flex: 1, padding: '0.5rem', background: '#dc3545', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' },
rawOcrBtn: { flex: 1, padding: '0.5rem', background: '#6c757d', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' },
backBtn: { marginTop: '0.5rem', padding: '0.5rem', background: 'transparent', border: 'none', color: '#666', cursor: 'pointer', width: '100%', fontSize: '0.9rem' },
modalOverlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: 'white', padding: '2rem', borderRadius: '12px', maxWidth: '400px', width: '90%' },
wideModal: { background: 'white', padding: '2rem', borderRadius: '12px', maxWidth: '800px', width: '90%', maxHeight: '80vh', overflowY: 'auto' },
modalInfo: { margin: '1rem 0', padding: '1rem', background: '#f5f5f5', borderRadius: '8px' },
modalDuplicateInfo: { padding: '0.75rem', background: '#d4edda', borderRadius: '6px', marginBottom: '1rem', color: '#155724', fontSize: '0.9rem' },
modalActions: { display: 'flex', gap: '1rem', marginTop: '1.5rem' },
cancelBtn: { flex: 1, padding: '0.75rem', background: '#f0f0f0', border: 'none', borderRadius: '6px', cursor: 'pointer' },
confirmDeleteBtn: { flex: 1, padding: '0.75rem', background: '#dc3545', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' },
compareGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginTop: '1rem' },
compareCard: { padding: '1rem', border: '2px solid #ddd', borderRadius: '8px' },
viewBtn: { marginTop: '0.5rem', padding: '0.5rem', background: '#1a1a2e', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', width: '100%', fontSize: '0.85rem' },
closeBtn: { marginTop: '1.5rem', padding: '0.75rem 2rem', background: '#1a1a2e', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
rawOcrModal: { background: 'white', padding: '2rem', borderRadius: '12px', maxWidth: '900px', width: '95%', maxHeight: '85vh', overflowY: 'auto' },
rawOcrHint: { color: '#666', fontSize: '0.9rem', marginBottom: '1rem' },
rawOcrContent: { marginTop: '1rem' },
rawOcrDoc: { marginBottom: '1.5rem', padding: '1rem', background: '#f8f9fa', borderRadius: '8px' },
rawOcrTable: { width: '100%', borderCollapse: 'collapse', marginTop: '0.5rem', fontSize: '0.85rem' },
rawOcrTh: { textAlign: 'left', padding: '0.5rem', borderBottom: '2px solid #ddd', fontWeight: '600', background: '#e9ecef' },
rawOcrTd: { padding: '0.5rem', borderBottom: '1px solid #eee', verticalAlign: 'top', wordBreak: 'break-word', maxWidth: '300px' },
lineItemPreviewCell: { padding: '0.5rem', background: '#f8f9fa', borderBottom: '1px solid #eee', textAlign: 'center' },
lineItemPreviewContainer: { width: '100%', overflow: 'hidden', display: 'inline-block' },
lineItemPreviewCrop: { width: '100%', backgroundRepeat: 'no-repeat' },
scaleBtn: { padding: '0.25rem', background: 'transparent', borderWidth: '1px', borderStyle: 'solid', borderColor: '#ddd', borderRadius: '4px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' },
scaleBtnActive: { background: '#e8f5e9', borderColor: '#28a745' },
costBreakdownCell: { padding: '0', background: '#f8f9fa', borderBottom: '1px solid #ddd' },
costBreakdownContainer: { padding: '1rem', margin: '0.5rem', background: '#fff', borderRadius: '8px', border: '1px solid #e0e0e0', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
costBreakdownHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', paddingBottom: '0.5rem', borderBottom: '1px solid #eee' },
rawContentHint: { fontSize: '0.75rem', color: '#999', fontStyle: 'italic', maxWidth: '400px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
costBreakdownGrid: { display: 'flex', flexWrap: 'wrap', gap: '1rem', marginBottom: '1rem' },
costBreakdownField: { display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: '70px' },
costBreakdownInput: { padding: '0.4rem', borderRadius: '4px', border: '1px solid #ddd', fontSize: '0.9rem', width: '70px', MozAppearance: 'textfield', appearance: 'textfield' } as React.CSSProperties,
costBreakdownValue: { fontSize: '0.95rem', padding: '0.4rem 0' },
costBreakdownActions: { display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', paddingTop: '0.5rem', borderTop: '1px solid #eee' },
saveAsDefaultRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', marginBottom: '0.5rem', background: '#f8f9fa', borderRadius: '6px', border: '1px solid #e0e0e0' },
saveAsDefaultLabel: { display: 'flex', alignItems: 'center', fontSize: '0.9rem', cursor: 'pointer', fontWeight: '500' },
saveAsDefaultHint: { fontSize: '0.75rem', color: '#666', fontStyle: 'italic' },
savedByInfo: { display: 'flex', alignItems: 'center', padding: '0.6rem 0.75rem', marginBottom: '0.5rem', background: '#e8f4fd', borderRadius: '6px', border: '1px solid #b8daff', fontSize: '0.85rem', color: '#004085' },
// Search modal styles
searchBtn: { padding: '0.25rem 0.5rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.25rem' },
searchModal: { background: 'white', padding: '2rem', borderRadius: '12px', maxWidth: '900px', width: '95%', maxHeight: '85vh', overflowY: 'auto' },
searchContext: { color: '#666', marginBottom: '1rem', fontSize: '0.9rem' },
searchInputRow: { display: 'flex', gap: '0.5rem', marginBottom: '1rem' },
searchInput: { flex: 1, padding: '0.75rem', borderRadius: '6px', border: '1px solid #ddd', fontSize: '1rem' },
searchButton: { padding: '0.75rem 1.5rem', background: '#1a1a2e', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '500' },
searchResultsContainer: { marginTop: '1rem', maxHeight: '50vh', overflowY: 'auto' },
resultsInfo: { color: '#333', fontSize: '0.9rem', marginBottom: '1rem' },
supplierGroup: { marginBottom: '1.5rem', padding: '1rem', background: '#f8f9fa', borderRadius: '8px' },
supplierHeader: { margin: '0 0 0.75rem', color: '#1a1a2e', fontSize: '1rem' },
searchResultsTable: { width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' },
searchTh: { textAlign: 'left', padding: '0.5rem', borderBottom: '2px solid #ddd', fontWeight: '600', background: '#e9ecef' },
searchThRight: { textAlign: 'right', padding: '0.5rem', borderBottom: '2px solid #ddd', fontWeight: '600', background: '#e9ecef' },
searchTd: { padding: '0.5rem', borderBottom: '1px solid #eee' },
searchTdRight: { padding: '0.5rem', borderBottom: '1px solid #eee', textAlign: 'right' },
currentItemSection: { marginBottom: '1.5rem', padding: '1rem', background: '#e8f4f8', borderRadius: '8px', border: '2px solid #2196f3' },
currentItemHeader: { margin: '0 0 0.75rem', color: '#1565c0', fontSize: '1rem', fontWeight: '600' },
currentItemRow: { background: '#fff' },
invoiceLink: { color: '#1976d2', textDecoration: 'none', fontSize: '0.8rem', padding: '0.2rem 0.5rem', background: '#e3f2fd', borderRadius: '4px' },
// Error banner styles
errorBanner: { display: 'flex', gap: '1rem', padding: '1rem 1.5rem', marginBottom: '1.5rem', background: '#fff3cd', border: '2px solid #ffc107', borderRadius: '8px', color: '#856404' },
errorBannerIcon: { fontSize: '2rem', flexShrink: 0 },
errorBannerContent: { flex: 1 },
// Description swap modal styles
descSwapModal: { background: 'white', padding: '2rem', borderRadius: '12px', maxWidth: '600px', width: '90%', maxHeight: '80vh', overflowY: 'auto' },
descriptionOption: { padding: '1rem', borderRadius: '8px', background: '#f9f9f9', border: '2px solid #ccc', cursor: 'pointer', transition: 'background 0.2s' },
modalHint: { color: '#666', fontSize: '0.9rem', margin: 0 },
}