Spaces:
Sleeping
Sleeping
| 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<void>((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<string, unknown> | |
| ): 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<WorkspaceRole>('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<InvoiceExtraction | null>(null); | |
| const [createdRecord, setCreatedRecord] = useState<InvoiceRecord | null>(null); | |
| const [recordHistory, setRecordHistory] = useState<InvoiceRecord[]>([]); | |
| const [jobs, setJobs] = useState<ScanJob[]>([]); | |
| const [error, setError] = useState<string | null>(null); | |
| const [note, setNote] = useState<string | null>(null); | |
| const [meta, setMeta] = useState<{ startedAt: string; durationMs: number; model: string; provider: string; source: string } | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement | null>(null); | |
| const runTokenRef = useRef(0); | |
| const mountedRef = useRef(true); | |
| const currentStepRef = useRef<string | null>(null); | |
| const runStep = async ( | |
| runToken: number, | |
| template: InvoiceStepTemplate, | |
| payload?: Record<string, unknown> | |
| ): Promise<boolean> => { | |
| 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<ScanJob>) => { | |
| 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<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| const readErrorMessage = locale === 'zh-TW' ? '無法讀取檔案' : 'Unable to read file'; | |
| const previewUrl = await new Promise<string>((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 ( | |
| <div className="invoice-demo-root"> | |
| <InvoiceFeatureDemo locale={locale} isBusy={isScanning} /> | |
| <header className="invoice-demo-header"> | |
| <div> | |
| <h3>{copy.title}</h3> | |
| <p>{copy.subtitle}</p> | |
| </div> | |
| <div className={`status-pill ${isScanning ? 'busy' : 'idle'}`}>{isScanning ? copy.statusRunning : copy.statusReady}</div> | |
| </header> | |
| <div className="invoice-demo-layout"> | |
| <section className="invoice-input-panel"> | |
| <div className="invoice-field-grid"> | |
| <label className="invoice-field-block"> | |
| <span>{copy.roleLabel}</span> | |
| <select value={role} onChange={(event) => setRole(event.target.value as WorkspaceRole)} disabled={isScanning}> | |
| <option value="farmer">{copy.roleFarmer}</option> | |
| <option value="market">{copy.roleMarket}</option> | |
| </select> | |
| </label> | |
| </div> | |
| <div className="invoice-field-block"> | |
| <span>{copy.sourceLabel}</span> | |
| <input ref={fileInputRef} type="file" accept="image/*" className="hidden-input" onChange={handleUpload} /> | |
| <button | |
| type="button" | |
| className={`invoice-dropzone ${uploadedFile ? 'has-file' : ''}`} | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={isScanning} | |
| > | |
| <strong>{uploadedFile ? uploadedFile.name : copy.upload}</strong> | |
| <small> | |
| {uploadedFile ? `${uploadedFile.mimeType} • ${uploadedFile.sizeKb} KB` : locale === 'zh-TW' ? '點擊上傳發票圖片' : 'Click to upload invoice image'} | |
| </small> | |
| </button> | |
| {uploadedFile ? ( | |
| <div className="invoice-file-preview"> | |
| <img src={uploadedFile.previewUrl} alt={uploadedFile.name} /> | |
| <div className="invoice-file-meta"> | |
| <span>{uploadedFile.name}</span> | |
| <button | |
| type="button" | |
| className="ghost-btn" | |
| onClick={() => { | |
| setUploadedFile(null); | |
| if (fileInputRef.current) fileInputRef.current.value = ''; | |
| }} | |
| disabled={isScanning} | |
| > | |
| {copy.removeFile} | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <p className="invoice-help-text">{copy.noFile}</p> | |
| )} | |
| </div> | |
| <div className="invoice-toggle-row"> | |
| <label> | |
| <input | |
| type="checkbox" | |
| checked={createRecord} | |
| onChange={(event) => setCreateRecord(event.target.checked)} | |
| disabled={isScanning || mode === 'live'} | |
| /> | |
| <span>{copy.createRecord}</span> | |
| </label> | |
| </div> | |
| <label className="invoice-field-block"> | |
| <span>{copy.noteLabel}</span> | |
| <textarea | |
| rows={4} | |
| value={scanNote} | |
| onChange={(event) => setScanNote(event.target.value)} | |
| placeholder={copy.notePlaceholder} | |
| disabled={isScanning} | |
| /> | |
| </label> | |
| <div className="invoice-action-row"> | |
| <button type="button" className="primary-btn" onClick={() => void runExtraction()} disabled={isScanning}> | |
| {copy.run} | |
| </button> | |
| {isScanning ? ( | |
| <button type="button" className="ghost-btn" onClick={stopScan}> | |
| {copy.stop} | |
| </button> | |
| ) : null} | |
| <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isScanning}> | |
| {copy.reset} | |
| </button> | |
| </div> | |
| <section className="invoice-jobs-block"> | |
| <h4>{copy.jobsTitle}</h4> | |
| {jobs.length === 0 ? ( | |
| <p className="invoice-help-text">{copy.queueEmpty}</p> | |
| ) : ( | |
| <ul className="invoice-job-list"> | |
| {jobs.map((job) => ( | |
| <li key={job.id} className={`invoice-job-card ${job.status}`}> | |
| <header> | |
| <strong>{job.source}</strong> | |
| <span>{job.createdAt}</span> | |
| </header> | |
| <p className="invoice-job-status">{copy.jobStatus[job.status]}</p> | |
| <div className="invoice-job-meter"> | |
| <span style={{ width: `${Math.min(100, Math.max(0, job.progress))}%` }} /> | |
| </div> | |
| {job.invoiceNumber ? <small>#{job.invoiceNumber}</small> : null} | |
| {job.error ? <small>{job.error}</small> : null} | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </section> | |
| </section> | |
| <section className="invoice-output-panel"> | |
| <header> | |
| <h4>{copy.outputTitle}</h4> | |
| <p>{copy.outputSubtitle}</p> | |
| </header> | |
| {note ? <div className="invoice-note">{note}</div> : null} | |
| {error ? <div className="invoice-error">{error}</div> : null} | |
| {!extraction && !isScanning ? <div className="invoice-empty">{copy.emptyOutput}</div> : null} | |
| {extraction ? ( | |
| <> | |
| <div className="invoice-metric-grid"> | |
| <article className="invoice-metric-card"> | |
| <span>{copy.metricConfidence}</span> | |
| <strong>{Math.round(extraction.confidence * 100)}%</strong> | |
| </article> | |
| <article className="invoice-metric-card"> | |
| <span>{copy.metricTotal}</span> | |
| <strong>{formatMoney(locale, extraction.invoice.total, extraction.invoice.currency)}</strong> | |
| </article> | |
| <article className="invoice-metric-card"> | |
| <span>{copy.metricItems}</span> | |
| <strong>{extraction.invoice.items.length}</strong> | |
| </article> | |
| <article className="invoice-metric-card"> | |
| <span>{copy.metricWarnings}</span> | |
| <strong>{extraction.warnings.length}</strong> | |
| </article> | |
| </div> | |
| <article className="invoice-preview-card"> | |
| <h5>{copy.fieldsTitle}</h5> | |
| <dl className="invoice-preview-grid"> | |
| <div> | |
| <dt>{copy.labels.invoiceNumber}</dt> | |
| <dd>{extraction.invoice.invoiceNumber}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.counterparty}</dt> | |
| <dd>{extraction.invoice.counterpartyName}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.status}</dt> | |
| <dd>{statusLabel(locale, extraction.invoice.status)}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.direction}</dt> | |
| <dd>{directionLabel(locale, extraction.invoice.direction)}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.issueDate}</dt> | |
| <dd>{extraction.invoice.issueDate}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.dueDate}</dt> | |
| <dd>{extraction.invoice.dueDate}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.deliveryDate}</dt> | |
| <dd>{extraction.invoice.deliveryDate}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.currency}</dt> | |
| <dd>{extraction.invoice.currency}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.total}</dt> | |
| <dd>{formatMoney(locale, extraction.invoice.total, extraction.invoice.currency)}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.paid}</dt> | |
| <dd>{formatMoney(locale, extraction.invoice.paid, extraction.invoice.currency)}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.balance}</dt> | |
| <dd>{formatMoney(locale, balance, extraction.invoice.currency)}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.description}</dt> | |
| <dd>{extraction.invoice.description}</dd> | |
| </div> | |
| </dl> | |
| </article> | |
| <article className="invoice-preview-card"> | |
| <h5>{copy.lineItemsTitle}</h5> | |
| <div className="invoice-items-scroll"> | |
| <table className="invoice-items-table"> | |
| <thead> | |
| <tr> | |
| <th>{locale === 'zh-TW' ? '品項' : 'Description'}</th> | |
| <th>{locale === 'zh-TW' ? '數量' : 'Qty'}</th> | |
| <th>{locale === 'zh-TW' ? '單價' : 'Unit Price'}</th> | |
| <th>{locale === 'zh-TW' ? '小計' : 'Line Total'}</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {extraction.invoice.items.map((item) => ( | |
| <tr key={`${item.description}-${item.quantity}`}> | |
| <td>{item.description}</td> | |
| <td>{item.quantity}</td> | |
| <td>{formatMoney(locale, item.unitPrice, extraction.invoice.currency)}</td> | |
| <td>{formatMoney(locale, item.lineTotal, extraction.invoice.currency)}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </article> | |
| <article className="invoice-preview-card"> | |
| <h5>{copy.rawTextTitle}</h5> | |
| <pre className="invoice-raw-text">{rawTextStream || extraction.rawText}</pre> | |
| </article> | |
| <details className="invoice-json-card"> | |
| <summary>{copy.jsonTitle}</summary> | |
| <pre>{JSON.stringify(extraction, null, 2)}</pre> | |
| </details> | |
| {meta ? ( | |
| <article className="invoice-meta-card"> | |
| <h5>{copy.metadataTitle}</h5> | |
| <dl> | |
| <div> | |
| <dt>{copy.labels.provider}</dt> | |
| <dd>{meta.provider}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.model}</dt> | |
| <dd>{meta.model}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.startedAt}</dt> | |
| <dd>{meta.startedAt}</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.duration}</dt> | |
| <dd>{meta.durationMs} ms</dd> | |
| </div> | |
| <div> | |
| <dt>{copy.labels.source}</dt> | |
| <dd>{meta.source}</dd> | |
| </div> | |
| </dl> | |
| </article> | |
| ) : null} | |
| </> | |
| ) : null} | |
| {createdRecord ? ( | |
| <article className="invoice-record-card"> | |
| <h5>{copy.createdRecordTitle}</h5> | |
| <p> | |
| <strong>{createdRecord.invoiceNumber}</strong> • {createdRecord.counterpartyName} | |
| </p> | |
| <p> | |
| {formatMoney(locale, createdRecord.total, createdRecord.currency)} • {statusLabel(locale, createdRecord.status)} | |
| </p> | |
| </article> | |
| ) : null} | |
| {recordHistory.length > 0 ? ( | |
| <article className="invoice-record-card"> | |
| <h5>{copy.historyTitle}</h5> | |
| <ul className="invoice-history-list"> | |
| {recordHistory.map((record) => ( | |
| <li key={record.id}> | |
| <span>#{record.invoiceNumber}</span> | |
| <strong>{formatMoney(locale, record.total, record.currency)}</strong> | |
| </li> | |
| ))} | |
| </ul> | |
| </article> | |
| ) : null} | |
| </section> | |
| </div> | |
| </div> | |
| ); | |
| } | |