import { ChangeEvent, useEffect, useRef, useState } from 'react'; import type { DemoLocale, DemoMode, ProgressStep, ProgressStepStatus, TraceItem } from './types'; import InvoiceFeatureDemo from './InvoiceFeatureDemo'; import { parseDataUrl, scanInvoiceImage } from './liveApi'; import type { LiveInvoiceExtraction } from './liveApi'; type InvoiceDirection = 'sale' | 'purchase'; type InvoiceStatus = 'draft' | 'open' | 'paid' | 'overdue'; type WorkspaceRole = 'farmer' | 'market'; type InvoiceLineItem = { description: string; quantity: number; unitPrice: number; lineTotal: number; }; type InvoiceExtraction = { provider: 'gemini' | 'mock'; model: string; confidence: number; warnings: string[]; rawText: string; invoice: { direction: InvoiceDirection; status: InvoiceStatus; invoiceNumber: string; counterpartyName: string; description: string; currency: string; total: number; paid: number; issueDate: string; dueDate: string; deliveryDate: string; items: InvoiceLineItem[]; }; }; type InvoiceRecord = { id: string; invoiceNumber: string; counterpartyName: string; total: number; currency: string; status: InvoiceStatus; createdAt: string; }; type ScanJobStatus = 'queued' | 'scanning' | 'extracting' | 'validating' | 'ready' | 'failed'; type ScanJob = { id: string; source: string; status: ScanJobStatus; progress: number; createdAt: string; invoiceNumber?: string; error?: string; }; type InvoiceStepTemplate = { id: string; label: string; runningDetail: string; doneDetail: string; kind: TraceItem['kind']; delayMs: number; }; type InvoiceDemoPanelProps = { mode: DemoMode; locale: DemoLocale; onWorkingChange: (working: boolean) => void; onWorkflowInit: (steps: ProgressStep[]) => void; onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void; onTrace: (item: TraceItem) => void; onClearPanels: () => void; queuedPrompt: string | null; onConsumeQueuedPrompt: () => void; }; const COPY = { en: { title: 'Invoice AI Studio', subtitle: 'Simulated invoice OCR and structured extraction (frontend-only demo)', statusReady: 'Ready', statusRunning: 'Scanning…', roleLabel: 'Workspace Role', roleFarmer: 'Farmer', roleMarket: 'Market / Buyer', sourceLabel: 'Invoice Source', upload: 'Upload Invoice', removeFile: 'Remove File', noFile: 'No file uploaded. A synthetic invoice sample will be used.', createRecord: 'Create invoice record after extraction', noteLabel: 'Scan Notes', notePlaceholder: 'Optional context for this scan run (vendor name, amount hints, etc.).', run: 'Run Extraction', stop: 'Stop', reset: 'Reset Output', jobsTitle: 'Scan Queue', queueEmpty: 'No active scan jobs yet.', outputTitle: 'Extraction Output', outputSubtitle: 'JSON + human-friendly invoice preview', emptyOutput: 'Upload or run a synthetic scan to view extracted invoice fields.', liveUploadRequired: 'Live mode requires an uploaded invoice image.', stoppedByUser: 'Invoice scan was stopped by user.', recordCreated: 'Draft invoice record created in demo history.', reviewReady: 'Extraction completed. Review fields before creating a record.', fieldsTitle: 'Extracted Fields', lineItemsTitle: 'Line Items', rawTextTitle: 'Raw OCR Text', jsonTitle: 'Structured JSON', metadataTitle: 'Run Metadata', createdRecordTitle: 'Created Record', historyTitle: 'Recent Records', metricConfidence: 'Confidence', metricTotal: 'Invoice Total', metricItems: 'Line Items', metricWarnings: 'Warnings', jobStatus: { queued: 'Queued', scanning: 'Scanning image', extracting: 'Extracting fields', validating: 'Validating totals', ready: 'Ready', failed: 'Failed', }, labels: { invoiceNumber: 'Invoice #', counterparty: 'Buyer / Supplier', status: 'Status', direction: 'Type', issueDate: 'Issue Date', dueDate: 'Due Date', deliveryDate: 'Delivery Date', currency: 'Currency', total: 'Total', paid: 'Paid', balance: 'Balance', description: 'Description', provider: 'Provider', model: 'Model', startedAt: 'Started At', duration: 'Duration', source: 'Source', }, }, 'zh-TW': { title: '發票 AI 工作室', subtitle: '前端模擬發票 OCR 與結構化擷取流程(不連後端)', statusReady: '就緒', statusRunning: '掃描中…', roleLabel: '工作模式', roleFarmer: '農民', roleMarket: '通路 / 買家', sourceLabel: '發票來源', upload: '上傳發票', removeFile: '移除檔案', noFile: '尚未上傳檔案,將使用模擬發票樣本。', createRecord: '擷取完成後建立發票紀錄', noteLabel: '掃描備註', notePlaceholder: '可補充本次掃描背景(供應商、金額提示等)。', run: '開始擷取', stop: '停止', reset: '重置輸出', jobsTitle: '掃描佇列', queueEmpty: '目前沒有掃描工作。', outputTitle: '擷取結果', outputSubtitle: 'JSON 與可閱讀發票預覽', emptyOutput: '可先上傳圖片,或直接執行模擬掃描查看欄位結果。', liveUploadRequired: '即時模式需要先上傳發票圖片。', stoppedByUser: '掃描流程已由使用者停止。', recordCreated: '已在示範歷史中建立草稿發票。', reviewReady: '欄位擷取完成,請先檢查後再建立紀錄。', fieldsTitle: '欄位摘要', lineItemsTitle: '品項明細', rawTextTitle: '原始 OCR 文字', jsonTitle: '結構化 JSON', metadataTitle: '執行資訊', createdRecordTitle: '已建立紀錄', historyTitle: '最近建立紀錄', metricConfidence: '信心值', metricTotal: '發票總額', metricItems: '品項數', metricWarnings: '警告', jobStatus: { queued: '排隊中', scanning: '掃描影像', extracting: '欄位擷取', validating: '驗證總額', ready: '完成', failed: '失敗', }, labels: { invoiceNumber: '發票號碼', counterparty: '買家 / 供應商', status: '狀態', direction: '類型', issueDate: '開立日期', dueDate: '到期日', deliveryDate: '配送日期', currency: '幣別', total: '總額', paid: '已付', balance: '未付', description: '備註', provider: '提供者', model: '模型', startedAt: '開始時間', duration: '耗時', source: '來源', }, }, } as const; const now = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }); const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); function makeId(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } function todayIso(): string { return new Date().toISOString().slice(0, 10); } function addDaysIso(baseIso: string, days: number): string { const base = new Date(`${baseIso}T00:00:00`); base.setDate(base.getDate() + days); return base.toISOString().slice(0, 10); } function statusLabel(locale: DemoLocale, status: InvoiceStatus): string { if (locale === 'zh-TW') { if (status === 'paid') return '已付款'; if (status === 'open') return '未結清'; if (status === 'overdue') return '逾期'; return '草稿'; } if (status === 'paid') return 'Paid'; if (status === 'open') return 'Open'; if (status === 'overdue') return 'Overdue'; return 'Draft'; } function directionLabel(locale: DemoLocale, direction: InvoiceDirection): string { if (locale === 'zh-TW') { return direction === 'sale' ? '銷項' : '進項'; } return direction === 'sale' ? 'Sale' : 'Purchase'; } function formatMoney(locale: DemoLocale, amount: number, currency: string): string { const language = locale === 'zh-TW' ? 'zh-TW' : 'en-US'; try { return new Intl.NumberFormat(language, { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount); } catch (error) { const safeAmount = Number.isFinite(amount) ? amount.toFixed(2) : '0.00'; return `${currency} ${safeAmount}`; } } function makeTrace( kind: TraceItem['kind'], title: string, detail: string, status: TraceItem['status'], payload?: Record ): TraceItem { return { id: makeId('trace'), kind, title, detail, status, payload, timestamp: now(), }; } function splitTextChunks(text: string): string[] { const words = text.split(/\s+/).filter(Boolean); const chunks: string[] = []; let current = ''; for (const word of words) { const next = current ? `${current} ${word}` : word; if (next.length > 34) { if (current) chunks.push(`${current} `); current = word; } else { current = next; } } if (current) chunks.push(`${current} `); return chunks; } function buildSteps(locale: DemoLocale, createRecord: boolean): InvoiceStepTemplate[] { if (locale === 'zh-TW') { return [ { id: 'invoice-step-1', label: '驗證發票來源', runningDetail: '檢查檔案類型與可讀性。', doneDetail: '文件前處理完成。', kind: 'validator', delayMs: 460, }, { id: 'invoice-step-2', label: '執行 OCR', runningDetail: '擷取原始文字內容。', doneDetail: 'OCR 掃描完成。', kind: 'tool', delayMs: 0, }, { id: 'invoice-step-3', label: '結構化欄位擷取', runningDetail: '解析發票號碼、日期、金額與品項。', doneDetail: '欄位擷取完成。', kind: 'tool', delayMs: 560, }, { id: 'invoice-step-4', label: '欄位驗證', runningDetail: '驗證總額、日期格式與狀態。', doneDetail: '欄位一致性驗證完成。', kind: 'validator', delayMs: 460, }, { id: 'invoice-step-5', label: createRecord ? '建立草稿紀錄' : '封裝審核結果', runningDetail: createRecord ? '建立可編輯的草稿發票。' : '整理供人工審核的輸出。', doneDetail: createRecord ? '草稿發票已建立。' : '審核輸出已完成。', kind: 'renderer', delayMs: 420, }, ]; } return [ { id: 'invoice-step-1', label: 'Validate invoice source', runningDetail: 'Checking file type and readability.', doneDetail: 'Document pre-processing complete.', kind: 'validator', delayMs: 460, }, { id: 'invoice-step-2', label: 'Run OCR', runningDetail: 'Extracting raw invoice text.', doneDetail: 'OCR pass completed.', kind: 'tool', delayMs: 0, }, { id: 'invoice-step-3', label: 'Extract structured fields', runningDetail: 'Parsing invoice #, dates, totals, and line items.', doneDetail: 'Structured extraction completed.', kind: 'tool', delayMs: 560, }, { id: 'invoice-step-4', label: 'Validate fields', runningDetail: 'Checking totals, date formats, and status.', doneDetail: 'Validation checks completed.', kind: 'validator', delayMs: 460, }, { id: 'invoice-step-5', label: createRecord ? 'Create draft record' : 'Package review output', runningDetail: createRecord ? 'Preparing editable draft invoice.' : 'Preparing review-ready output.', doneDetail: createRecord ? 'Draft invoice record created.' : 'Review package prepared.', kind: 'renderer', delayMs: 420, }, ]; } function buildItems(locale: DemoLocale): InvoiceLineItem[] { if (locale === 'zh-TW') { return [ { description: '放牧雞蛋(10 入)', quantity: 20, unitPrice: 118, lineTotal: 2360 }, { description: '有機青菜組', quantity: 12, unitPrice: 220, lineTotal: 2640 }, { description: '甜玉米箱', quantity: 15, unitPrice: 95, lineTotal: 1425 }, ]; } return [ { description: 'Free-range eggs (10 pack)', quantity: 20, unitPrice: 118, lineTotal: 2360 }, { description: 'Organic greens bundle', quantity: 12, unitPrice: 220, lineTotal: 2640 }, { description: 'Sweet corn box', quantity: 15, unitPrice: 95, lineTotal: 1425 }, ]; } function buildMockExtraction(locale: DemoLocale, role: WorkspaceRole, note: string): InvoiceExtraction { const issueDate = todayIso(); const dueDate = addDaysIso(issueDate, 21); const deliveryDate = addDaysIso(issueDate, -2); const items = buildItems(locale); const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0); const fee = 240; const total = subtotal + fee; const paid = note.toLowerCase().includes('paid') || note.includes('已付款') ? total : 0; const direction: InvoiceDirection = role === 'farmer' ? 'sale' : 'purchase'; const invoiceNumber = locale === 'zh-TW' ? `TW-FTL-${issueDate.replace(/-/g, '')}-${Math.floor(100 + Math.random() * 800)}` : `FTL-${issueDate.replace(/-/g, '')}-${Math.floor(100 + Math.random() * 800)}`; const counterpartyName = role === 'farmer' ? locale === 'zh-TW' ? '晨光食材通路' : 'Morning Harvest Distribution' : locale === 'zh-TW' ? '北埔綠田農場' : 'Beipu Greenfield Farm'; const descriptionCore = locale === 'zh-TW' ? '本週配送批次結算。' : 'Weekly delivery batch settlement.'; const description = note.trim() ? `${descriptionCore} ${note.trim()}` : descriptionCore; const warnings = locale === 'zh-TW' ? ['印章區略模糊,建議人工覆核發票號碼。'] : ['Stamp region is slightly blurred; verify invoice number manually.']; const rawText = locale === 'zh-TW' ? [ `發票號碼: ${invoiceNumber}`, `買方/賣方: ${counterpartyName}`, `開立日期: ${issueDate}`, `到期日期: ${dueDate}`, ...items.map((item) => `${item.description} x${item.quantity} @${item.unitPrice} = ${item.lineTotal}`), `運費: ${fee}`, `總計: ${total}`, `已付: ${paid}`, ].join('\n') : [ `Invoice #: ${invoiceNumber}`, `Counterparty: ${counterpartyName}`, `Issue date: ${issueDate}`, `Due date: ${dueDate}`, ...items.map((item) => `${item.description} x${item.quantity} @${item.unitPrice} = ${item.lineTotal}`), `Handling fee: ${fee}`, `Total: ${total}`, `Paid: ${paid}`, ].join('\n'); return { provider: 'gemini', model: 'gemini-2.5-flash (simulated)', confidence: 0.84 + Math.random() * 0.12, warnings, rawText, invoice: { direction, status: paid > 0 ? 'paid' : 'draft', invoiceNumber, counterpartyName, description, currency: 'TWD', total, paid, issueDate, dueDate, deliveryDate, items, }, }; } function toNumber(value: unknown, fallback = 0): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function normalizeLiveExtraction(payload: LiveInvoiceExtraction | undefined, locale: DemoLocale): InvoiceExtraction { const invoice = payload?.invoice || {}; const items = Array.isArray(invoice.items) ? invoice.items : []; return { provider: payload?.provider === 'gemini' || payload?.provider === 'mock' ? payload.provider : 'gemini', model: payload?.model || (locale === 'zh-TW' ? 'gemini(live)' : 'gemini (live)'), confidence: toNumber(payload?.confidence, 0.5), warnings: Array.isArray(payload?.warnings) ? payload.warnings.map((item) => String(item)) : [], rawText: typeof payload?.rawText === 'string' ? payload.rawText : '', invoice: { direction: invoice.direction === 'purchase' ? 'purchase' : 'sale', status: invoice.status === 'open' || invoice.status === 'paid' || invoice.status === 'overdue' ? invoice.status : 'draft', invoiceNumber: typeof invoice.invoiceNumber === 'string' ? invoice.invoiceNumber : '', counterpartyName: typeof invoice.counterpartyName === 'string' ? invoice.counterpartyName : '', description: typeof invoice.description === 'string' ? invoice.description : '', currency: typeof invoice.currency === 'string' ? invoice.currency : 'TWD', total: toNumber(invoice.total), paid: toNumber(invoice.paid), issueDate: typeof invoice.issueDate === 'string' ? invoice.issueDate : '', dueDate: typeof invoice.dueDate === 'string' ? invoice.dueDate : '', deliveryDate: typeof invoice.deliveryDate === 'string' ? invoice.deliveryDate : '', items: items.map((item) => ({ description: typeof item.description === 'string' ? item.description : '', quantity: toNumber(item.quantity), unitPrice: toNumber(item.unitPrice), lineTotal: toNumber(item.lineTotal), })), }, }; } export default function InvoiceDemoPanel({ mode, locale, onWorkingChange, onWorkflowInit, onStepStatus, onTrace, onClearPanels, queuedPrompt, onConsumeQueuedPrompt, }: InvoiceDemoPanelProps) { const copy = COPY[locale]; const [role, setRole] = useState('farmer'); const [createRecord, setCreateRecord] = useState(true); const [scanNote, setScanNote] = useState(''); const [uploadedFile, setUploadedFile] = useState<{ name: string; previewUrl: string; mimeType: string; sizeKb: number; } | null>(null); const [isScanning, setIsScanning] = useState(false); const [rawTextStream, setRawTextStream] = useState(''); const [extraction, setExtraction] = useState(null); const [createdRecord, setCreatedRecord] = useState(null); const [recordHistory, setRecordHistory] = useState([]); const [jobs, setJobs] = useState([]); const [error, setError] = useState(null); const [note, setNote] = useState(null); const [meta, setMeta] = useState<{ startedAt: string; durationMs: number; model: string; provider: string; source: string } | null>(null); const fileInputRef = useRef(null); const runTokenRef = useRef(0); const mountedRef = useRef(true); const currentStepRef = useRef(null); const runStep = async ( runToken: number, template: InvoiceStepTemplate, payload?: Record ): Promise => { if (!mountedRef.current || runTokenRef.current !== runToken) return false; currentStepRef.current = template.id; onStepStatus(template.id, 'in_progress', template.runningDetail); onTrace(makeTrace(template.kind, template.label, template.runningDetail, 'running')); if (template.delayMs > 0) { await sleep(template.delayMs); } if (!mountedRef.current || runTokenRef.current !== runToken) return false; onStepStatus(template.id, 'completed', template.doneDetail); onTrace(makeTrace(template.kind, template.label, template.doneDetail, 'ok', payload)); return true; }; const setJobState = (jobId: string, patch: Partial) => { setJobs((prev) => prev.map((job) => (job.id === jobId ? { ...job, ...patch } : job))); }; const stopScan = () => { if (!isScanning) return; runTokenRef.current += 1; if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', copy.stoppedByUser); } onTrace(makeTrace('tool', locale === 'zh-TW' ? '掃描中止' : 'Scan stopped', copy.stoppedByUser, 'error')); setJobs((prev) => prev.map((job, index) => { if (index > 0) return job; if (job.status === 'ready' || job.status === 'failed') return job; return { ...job, status: 'failed', error: copy.stoppedByUser, progress: 100 }; }) ); setError(copy.stoppedByUser); setIsScanning(false); onWorkingChange(false); }; const resetOutput = () => { setRawTextStream(''); setExtraction(null); setCreatedRecord(null); setError(null); setMeta(null); setNote(null); onClearPanels(); }; const runExtraction = async (forcedNote?: string) => { const activeNote = (forcedNote ?? scanNote).trim(); const source = uploadedFile?.name || (locale === 'zh-TW' ? '模擬發票樣本' : 'Synthetic invoice sample'); const liveCreateRecord = mode === 'live' ? false : createRecord; const steps = buildSteps(locale, liveCreateRecord); const runToken = runTokenRef.current + 1; runTokenRef.current = runToken; const startedAtMs = Date.now(); const jobId = makeId('job'); const pendingJob: ScanJob = { id: jobId, source, status: 'queued', progress: 2, createdAt: now(), }; setJobs((prev) => [pendingJob, ...prev].slice(0, 6) ); onClearPanels(); setIsScanning(true); onWorkingChange(true); setRawTextStream(''); setExtraction(null); setCreatedRecord(null); setError(null); setMeta(null); setNote(null); onWorkflowInit( steps.map((step) => ({ id: step.id, label: step.label, detail: step.runningDetail, status: 'pending', })) ); try { if (mode === 'live') { if (!uploadedFile) { throw new Error(copy.liveUploadRequired); } setJobState(jobId, { status: 'scanning', progress: 16 }); const sourceReady = await runStep(runToken, steps[0], { source: uploadedFile.mimeType, }); if (!sourceReady) return; const ocrStep = steps[1]; currentStepRef.current = ocrStep.id; onStepStatus(ocrStep.id, 'in_progress', ocrStep.runningDetail); onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.runningDetail, 'running')); const imagePayload = parseDataUrl(uploadedFile.previewUrl); const response = await scanInvoiceImage({ dataBase64: imagePayload.base64, mimeType: imagePayload.mimeType, locale, createInvoice: liveCreateRecord, }); const payload = normalizeLiveExtraction(response.extraction, locale); if (!mountedRef.current || runTokenRef.current !== runToken) return; const chunks = splitTextChunks(payload.rawText || ''); for (let i = 0; i < chunks.length; i += 1) { if (!mountedRef.current || runTokenRef.current !== runToken) return; setRawTextStream((prev) => prev + chunks[i]); if (i % 5 === 0) { onTrace( makeTrace( 'tool', locale === 'zh-TW' ? 'OCR 片段' : 'OCR chunk', locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`, 'ok' ) ); } await sleep(55); } onStepStatus(ocrStep.id, 'completed', ocrStep.doneDetail); onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.doneDetail, 'ok', { chars: payload.rawText.length })); setJobState(jobId, { status: 'extracting', progress: 62 }); const extracted = await runStep(runToken, steps[2], { fields: 12, items: payload.invoice.items.length, }); if (!extracted) return; setExtraction(payload); setJobState(jobId, { status: 'validating', progress: 82 }); const validated = await runStep(runToken, steps[3], { total: payload.invoice.total, paid: payload.invoice.paid, warnings: payload.warnings.length, }); if (!validated) return; const packaged = await runStep(runToken, steps[4], { createRecord: liveCreateRecord, invoiceNumber: payload.invoice.invoiceNumber, mode: 'live', }); if (!packaged) return; setNote(copy.reviewReady); setJobState(jobId, { status: 'ready', progress: 100, invoiceNumber: payload.invoice.invoiceNumber, }); setMeta({ startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startedAtMs, model: payload.model, provider: payload.provider, source, }); return; } const payload = buildMockExtraction(locale, role, activeNote); setJobState(jobId, { status: 'scanning', progress: 16 }); const sourceReady = await runStep(runToken, steps[0], { source: uploadedFile ? uploadedFile.mimeType : 'synthetic', }); if (!sourceReady) return; setJobState(jobId, { status: 'extracting', progress: 34 }); const ocrStep = steps[1]; currentStepRef.current = ocrStep.id; onStepStatus(ocrStep.id, 'in_progress', ocrStep.runningDetail); onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.runningDetail, 'running')); const chunks = splitTextChunks(payload.rawText); for (let i = 0; i < chunks.length; i += 1) { if (!mountedRef.current || runTokenRef.current !== runToken) return; setRawTextStream((prev) => prev + chunks[i]); if (i % 5 === 0) { onTrace( makeTrace( 'tool', locale === 'zh-TW' ? 'OCR 片段' : 'OCR chunk', locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`, 'ok' ) ); } await sleep(80); } if (!mountedRef.current || runTokenRef.current !== runToken) return; onStepStatus(ocrStep.id, 'completed', ocrStep.doneDetail); onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.doneDetail, 'ok', { chars: payload.rawText.length })); setJobState(jobId, { status: 'extracting', progress: 62 }); const extracted = await runStep(runToken, steps[2], { fields: 12, items: payload.invoice.items.length, }); if (!extracted) return; setExtraction(payload); setJobState(jobId, { status: 'validating', progress: 82 }); const validated = await runStep(runToken, steps[3], { total: payload.invoice.total, paid: payload.invoice.paid, warnings: payload.warnings.length, }); if (!validated) return; const packaged = await runStep(runToken, steps[4], { createRecord: liveCreateRecord, invoiceNumber: payload.invoice.invoiceNumber, }); if (!packaged) return; if (liveCreateRecord) { const record: InvoiceRecord = { id: makeId('record'), invoiceNumber: payload.invoice.invoiceNumber, counterpartyName: payload.invoice.counterpartyName, total: payload.invoice.total, currency: payload.invoice.currency, status: payload.invoice.status, createdAt: now(), }; setCreatedRecord(record); setRecordHistory((prev) => [record, ...prev].slice(0, 6)); setNote(copy.recordCreated); } else { setNote(copy.reviewReady); } setJobState(jobId, { status: 'ready', progress: 100, invoiceNumber: payload.invoice.invoiceNumber, }); setMeta({ startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), durationMs: Date.now() - startedAtMs, model: payload.model, provider: payload.provider, source, }); } catch (runtimeError) { const message = runtimeError instanceof Error ? runtimeError.message : copy.stoppedByUser; setError(message); if (currentStepRef.current) { onStepStatus(currentStepRef.current, 'error', message); } setJobState(jobId, { status: 'failed', progress: 100, error: message }); onTrace(makeTrace('tool', locale === 'zh-TW' ? '擷取錯誤' : 'Extraction error', message, 'error')); } finally { if (runTokenRef.current === runToken) { setIsScanning(false); onWorkingChange(false); } } }; const handleUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const readErrorMessage = locale === 'zh-TW' ? '無法讀取檔案' : 'Unable to read file'; const previewUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error(readErrorMessage)); return; } resolve(reader.result); }; reader.onerror = () => reject(new Error(readErrorMessage)); reader.readAsDataURL(file); }); setUploadedFile({ name: file.name, previewUrl, mimeType: file.type || 'image/*', sizeKb: Math.max(1, Math.round(file.size / 1024)), }); }; useEffect(() => { if (!queuedPrompt) return; const consume = async () => { setScanNote(queuedPrompt); onConsumeQueuedPrompt(); await runExtraction(queuedPrompt); }; void consume(); }, [queuedPrompt]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; runTokenRef.current += 1; onWorkingChange(false); }; }, []); useEffect(() => { if (mode === 'live') { setCreateRecord(false); } }, [mode]); const balance = extraction ? extraction.invoice.total - extraction.invoice.paid : 0; return (

{copy.title}

{copy.subtitle}

{isScanning ? copy.statusRunning : copy.statusReady}
{copy.sourceLabel} {uploadedFile ? (
{uploadedFile.name}
{uploadedFile.name}
) : (

{copy.noFile}

)}