Farm2Market / src /InvoiceDemoPanel.tsx
pixel3user
Update Farm2Market demo frontend
e46c4bd
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>
);
}