From c868ba8299a3ce6bb3202be8d3a5ff180e37a23c Mon Sep 17 00:00:00 2001 From: Isaac Araoluwa Abraham Date: Tue, 20 Oct 2026 23:46:38 +0000 Subject: [PATCH] feat: add invoice exports, OCR, and forecasting --- package-lock.json | 67 +++++++++++ package.json | 1 + src/__tests__/earningsForecast.test.ts | 21 +++- src/__tests__/exportHistory.test.ts | 30 +++++ src/__tests__/invoiceFormatExport.test.ts | 32 +++++ src/__tests__/invoiceOcr.test.ts | 28 +++++ src/app/invoice/import/page.tsx | 122 +++++++++++++------ src/components/EarningsForecastChart.tsx | 18 ++- src/components/InvoiceExportButton.tsx | 116 +++++++++++++++++- src/lib/earningsForecast.ts | 137 +++++++++++++++++----- src/lib/exportHistory.ts | 60 +++++++++- src/lib/invoiceFormatExport.ts | 107 +++++++++++++++++ src/lib/invoiceOcr.ts | 94 +++++++++++++++ vitest.setup.ts | 17 +-- 14 files changed, 756 insertions(+), 94 deletions(-) create mode 100644 src/__tests__/exportHistory.test.ts create mode 100644 src/__tests__/invoiceFormatExport.test.ts create mode 100644 src/__tests__/invoiceOcr.test.ts create mode 100644 src/lib/invoiceFormatExport.ts create mode 100644 src/lib/invoiceOcr.ts diff --git a/package-lock.json b/package-lock.json index 538b67cb..3faa6732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "recharts": "^2.12.7", "remark-gfm": "^4.0.1", "swr": "^2.4.2", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7", "zod": "^3.25.76", "zustand": "^5.0.14" @@ -7035,6 +7036,12 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.5", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", @@ -10091,6 +10098,12 @@ "integrity": "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q==", "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -13248,6 +13261,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -14380,6 +14402,12 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -15799,6 +15827,30 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -16895,6 +16947,12 @@ "node": ">=18" } }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -17840,6 +17898,15 @@ "node": ">= 10" } }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index aeabbb86..f0833c65 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "recharts": "^2.12.7", "remark-gfm": "^4.0.1", "swr": "^2.4.2", + "tesseract.js": "^7.0.0", "web-push": "^3.6.7", "zod": "^3.25.76", "zustand": "^5.0.14" diff --git a/src/__tests__/earningsForecast.test.ts b/src/__tests__/earningsForecast.test.ts index 9a10100b..19689538 100644 --- a/src/__tests__/earningsForecast.test.ts +++ b/src/__tests__/earningsForecast.test.ts @@ -1,4 +1,4 @@ -import { forecastEarnings, totalForecast } from "@/lib/earningsForecast"; +import { forecastEarnings, forecastEarningsDetailed, totalForecast } from "@/lib/earningsForecast"; describe("forecastEarnings", () => { it("projects a linear trend", () => { @@ -23,4 +23,23 @@ describe("forecastEarnings", () => { expect(forecastEarnings([], 3)).toEqual([]); expect(forecastEarnings([{ label: "x", value: 1 }], 0)).toEqual([{ label: "x", actual: 1 }]); }); + + it("evaluates candidate models using walk-forward error metrics", () => { + const history = [10, 20, 30, 40, 50, 60, 70, 80].map((value, index) => ({ label: `m${index}`, value })); + const result = forecastEarningsDetailed(history, 2); + expect(result.model).toBe("linear"); + expect(result.metrics).toMatchObject({ mae: 0, rmse: 0, mape: 0, sampleCount: 5 }); + expect(result.points[8].low).toBe(result.points[8].forecast); + expect(result.points[8].high).toBe(result.points[8].forecast); + expect(Number.isNaN(Date.parse(result.trainedAt))).toBe(false); + }); + + it("selects the model with lower walk-forward error for noisy history", () => { + const history = [10, 11, 10, 30, 10, 11, 10, 30, 10, 11, 10, 30].map((value, index) => ({ label: `m${index}`, value })); + const result = forecastEarningsDetailed(history, 2); + expect(result.model).toBe("moving-average"); + expect(result.metrics.sampleCount).toBe(9); + expect(result.points.slice(-2).every((point) => point.low! <= point.forecast! && point.high! >= point.forecast!)).toBe(true); + expect(result.points[13].high! - result.points[13].forecast!).toBeGreaterThan(result.points[12].high! - result.points[12].forecast!); + }); }); diff --git a/src/__tests__/exportHistory.test.ts b/src/__tests__/exportHistory.test.ts new file mode 100644 index 00000000..0615ac48 --- /dev/null +++ b/src/__tests__/exportHistory.test.ts @@ -0,0 +1,30 @@ +import { + addExportHistoryEntry, + completeScheduledInvoiceExport, + getExportHistory, + getScheduledInvoiceExports, + scheduleInvoiceExport, +} from '@/lib/exportHistory'; + +describe('invoice export history and scheduling', () => { + beforeEach(() => window.localStorage.clear()); + + it('records completed exports with their format and invoice filters', () => { + addExportHistoryEntry({ format: 'xml', filters: { invoiceId: 'invoice-1' }, recordCount: 1, status: 'completed' }); + expect(getExportHistory()).toMatchObject([{ format: 'xml', filters: { invoiceId: 'invoice-1' }, recordCount: 1, status: 'completed' }]); + }); + + it('persists a snapshot and marks a scheduled export complete', () => { + const job = scheduleInvoiceExport({ + invoiceId: 'invoice-1', + format: 'json', + scheduledAt: new Date(Date.now() + 60_000).toISOString(), + content: '{"id":"invoice-1"}', + mimeType: 'application/json', + extension: 'json', + }); + expect(getScheduledInvoiceExports()).toContainEqual(job); + completeScheduledInvoiceExport(job.id); + expect(getScheduledInvoiceExports()[0].status).toBe('completed'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/invoiceFormatExport.test.ts b/src/__tests__/invoiceFormatExport.test.ts new file mode 100644 index 00000000..4a88b47c --- /dev/null +++ b/src/__tests__/invoiceFormatExport.test.ts @@ -0,0 +1,32 @@ +import { generateInvoiceExport } from '@/lib/invoiceFormatExport'; +import type { Invoice } from '@stellar-split/sdk'; + +const invoice: Invoice = { + id: 'invoice-1', + creator: 'G', + recipients: [{ address: 'G&recipient', amount: 12500000n }], + payments: [{ payer: 'G"payer', amount: 2500000n }], + status: 'Pending', + funded: 2500000n, + token: 'USDC', + deadline: 0, +}; + +describe('generateInvoiceExport', () => { + it('preserves amount precision in JSON', () => { + const result = generateInvoiceExport(invoice, 12500000n, 'json'); + expect(result.extension).toBe('json'); + expect(JSON.parse(result.content)).toMatchObject({ total: '1.2500000', funded: '0.2500000' }); + }); + + it('escapes XML and HTML values', () => { + expect(generateInvoiceExport(invoice, 12500000n, 'xml').content).toContain('G<creator>'); + expect(generateInvoiceExport(invoice, 12500000n, 'html').content).toContain('G&recipient'); + }); + + it('renders values into a custom template', () => { + const result = generateInvoiceExport(invoice, 12500000n, 'custom', '{{id}}: {{total}} {{token}}'); + expect(result.content).toBe('invoice-1: 1.2500000 USDC'); + expect(result.extension).toBe('txt'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/invoiceOcr.test.ts b/src/__tests__/invoiceOcr.test.ts new file mode 100644 index 00000000..8ffedd3a --- /dev/null +++ b/src/__tests__/invoiceOcr.test.ts @@ -0,0 +1,28 @@ +import { getInvoiceOcrAnalytics, recognizeInvoiceFields, recordInvoiceOcrReview } from '@/lib/invoiceOcr'; + +describe('recognizeInvoiceFields', () => { + it('extracts invoice fields and scores recognized values', () => { + const address = `G${'A'.repeat(55)}`; + const result = recognizeInvoiceFields(`Recipient ${address}. Total due: $1,234.50 USDC. Due in 14 days.`, 90); + expect(result.fields).toEqual({ recipient_address: address, amount: '1234.50', deadline_days: '14', token: 'USDC' }); + expect(result.confidence.recipient_address).toBe(90); + expect(result.confidence.amount).toBeGreaterThan(70); + }); + + it('leaves unrecognized values empty with zero confidence', () => { + const result = recognizeInvoiceFields('An invoice without recognized fields', 75); + expect(result.fields.amount).toBe(''); + expect(result.confidence.amount).toBe(0); + }); +}); + +describe('OCR correction analytics', () => { + beforeEach(() => window.localStorage.clear()); + + it('tracks reviewed and corrected fields without storing invoice contents', () => { + const original = { recipient_address: 'G1', amount: '1', deadline_days: '5', token: 'USDC' }; + recordInvoiceOcrReview(original, { ...original, amount: '2' }); + expect(getInvoiceOcrAnalytics()).toEqual({ extractions: 1, reviewedFields: 4, correctedFields: 1 }); + expect(window.localStorage.getItem('invoice_ocr_analytics_v1')).not.toContain('G1'); + }); +}); \ No newline at end of file diff --git a/src/app/invoice/import/page.tsx b/src/app/invoice/import/page.tsx index 3afdf54d..c7c2bb9d 100644 --- a/src/app/invoice/import/page.tsx +++ b/src/app/invoice/import/page.tsx @@ -9,6 +9,13 @@ import { type ValidationResult, validateRows, } from "@/lib/bulkImporter"; +import { + getInvoiceOcrAnalytics, + recognizeInvoiceFields, + recordInvoiceOcrReview, + type InvoiceOcrField, + type InvoiceOcrFields, +} from "@/lib/invoiceOcr"; // --------------------------------------------------------------------------- // Types @@ -94,6 +101,46 @@ export default function ImportInvoicesPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [ocrFields, setOcrFields] = useState(null); + const [ocrConfidence, setOcrConfidence] = useState | null>(null); + const [ocrText, setOcrText] = useState(null); + const [ocrProgress, setOcrProgress] = useState(null); + const [ocrAnalytics, setOcrAnalytics] = useState(getInvoiceOcrAnalytics); + + const handleImageFile = useCallback(async (image: File) => { + const imageUrl = URL.createObjectURL(image); + setError(null); + setSuccess(null); + setOcrProgress("Loading OCR engine..."); + setOcrFields(null); + setParsed([]); + setResults([]); + try { + const { createWorker } = await import("tesseract.js"); + const worker = await createWorker("eng", 1, { + logger: (event) => setOcrProgress(`${event.status}${event.progress ? ` ${Math.round(event.progress * 100)}%` : ""}`), + }); + try { + const { data } = await worker.recognize(imageUrl); + const extraction = recognizeInvoiceFields(data.text, data.confidence); + const rows: InvoiceRow[] = [{ ...extraction.fields }]; + setParsed(rows); + setResults(validateRows(rows)); + setExcluded(new Set()); + setOcrFields(extraction.fields); + setOcrConfidence(extraction.confidence); + setOcrText(extraction.text); + setOcrProgress(null); + } finally { + await worker.terminate(); + } + } catch (err) { + setOcrProgress(null); + setError(err instanceof Error ? err.message : "Could not extract invoice text from this image."); + } finally { + URL.revokeObjectURL(imageUrl); + } + }, []); // ── File upload ──────────────────────────────────────────────────────────── @@ -106,6 +153,14 @@ export default function ImportInvoicesPage() { setError(null); setSuccess(null); setExcluded(new Set()); + setOcrFields(null); + setOcrConfidence(null); + setOcrText(null); + + if (f.type.startsWith("image/")) { + void handleImageFile(f); + return; + } const reader = new FileReader(); reader.onload = (event) => { @@ -129,9 +184,17 @@ export default function ImportInvoicesPage() { }; reader.readAsText(f); }, - [] + [handleImageFile] ); + const updateParsedField = useCallback((index: number, field: InvoiceOcrField, value: string) => { + setParsed((current) => { + const updated = current.map((row, rowIndex) => rowIndex === index ? { ...row, [field]: value } : row); + setResults(validateRows(updated)); + return updated; + }); + }, []); + // ── Checkbox toggle ──────────────────────────────────────────────────────── const toggleExclude = useCallback((idx: number) => { @@ -182,10 +245,17 @@ export default function ImportInvoicesPage() { } setSuccess(invoiceIds); + if (ocrFields && includableRows[0]) { + recordInvoiceOcrReview(ocrFields, includableRows[0]); + setOcrAnalytics(getInvoiceOcrAnalytics()); + } setParsed([]); setResults([]); setExcluded(new Set()); setFile(null); + setOcrFields(null); + setOcrConfidence(null); + setOcrText(null); } catch (err) { setError( err instanceof Error ? err.message : "Failed to create invoices" @@ -210,22 +280,25 @@ export default function ImportInvoicesPage() {

Import Invoices

- Upload a CSV file to bulk-create invoices. Review and fix (or exclude) - any invalid rows before importing. + Upload a CSV to bulk-create invoices or an image to extract invoice fields. Review and correct values before importing.

{/* ── Upload card ── */}
+ {ocrProgress &&

{ocrProgress}

} +

+ OCR analytics: {ocrAnalytics.extractions} reviewed, {ocrAnalytics.reviewedFields - ocrAnalytics.correctedFields} unchanged of {ocrAnalytics.reviewedFields} fields. +

Expected columns:{" "} recipient_address @@ -286,6 +359,8 @@ export default function ImportInvoicesPage() {

+ {ocrConfidence &&

Estimated OCR confidence: recipient {ocrConfidence.recipient_address}%, amount {ocrConfidence.amount}%, deadline {ocrConfidence.deadline_days}%, token {ocrConfidence.token}%.

} + {ocrText &&
Show recognized text
{ocrText}
} @@ -334,39 +409,10 @@ export default function ImportInvoicesPage() { {/* Data cells */} - - - - + + + + {/* Status */}
- {row.recipient_address || ( - - (empty) - - )} - - {row.amount} - - {row.deadline_days} - - {row.token} - updateParsedField(idx, "recipient_address", event.target.value)} className="w-44 rounded border border-gray-300 px-2 py-1 font-mono text-xs" /> updateParsedField(idx, "amount", event.target.value)} className="w-24 rounded border border-gray-300 px-2 py-1" /> updateParsedField(idx, "deadline_days", event.target.value)} className="w-20 rounded border border-gray-300 px-2 py-1" /> updateParsedField(idx, "token", event.target.value)} className="w-20 rounded border border-gray-300 px-2 py-1" /> diff --git a/src/components/EarningsForecastChart.tsx b/src/components/EarningsForecastChart.tsx index 49f723f3..f54f4525 100644 --- a/src/components/EarningsForecastChart.tsx +++ b/src/components/EarningsForecastChart.tsx @@ -11,7 +11,7 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { forecastEarnings, totalForecast, type EarningsPoint } from "@/lib/earningsForecast"; +import { forecastEarningsDetailed, totalForecast, type EarningsPoint } from "@/lib/earningsForecast"; interface Props { history: EarningsPoint[]; @@ -32,13 +32,16 @@ function nextMonthLabels(count: number): string[] { export default function EarningsForecastChart({ history }: Props) { const [horizon, setHorizon] = useState(3); + const forecast = useMemo( + () => forecastEarningsDetailed(history, horizon, nextMonthLabels(horizon)), + [history, horizon] + ); const data = useMemo( - () => - forecastEarnings(history, horizon, nextMonthLabels(horizon)).map((p) => ({ + () => forecast.points.map((p) => ({ ...p, band: p.low !== undefined && p.high !== undefined ? [p.low, p.high] : undefined, })), - [history, horizon] + [forecast.points] ); const total = useMemo(() => totalForecast(data), [data]); const hasData = history.some((h) => h.value > 0); @@ -76,6 +79,11 @@ export default function EarningsForecastChart({ history }: Props) { {total} USDC

+

+ {forecast.model === "linear" ? "Linear regression" : "3-period moving average"} · walk-forward MAE {forecast.metrics.mae} USDC · RMSE {forecast.metrics.rmse} USDC + {forecast.metrics.mape !== null ? ` · MAPE ${forecast.metrics.mape}%` : ""} + {forecast.metrics.sampleCount === 0 ? " · awaiting more history for validation" : ""} +

@@ -100,7 +108,7 @@ export default function EarningsForecastChart({ history }: Props) {

- Linear trend over your last {history.length} months; shaded area shows the expected range. + Model retrained on your latest {history.length} months; shaded area shows the 95% prediction interval.

)} diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index 5f407885..c236127f 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -1,9 +1,16 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import { formatAmount } from '@stellar-split/sdk'; import { DEFAULT_ACCENT_COLOR, BRAND_FONT_OPTIONS, type BrandSettings } from '@/lib/brandSettings'; +import { generateInvoiceExport, type InvoiceExportFormat } from '@/lib/invoiceFormatExport'; +import { + addExportHistoryEntry, + completeScheduledInvoiceExport, + getScheduledInvoiceExports, + scheduleInvoiceExport, +} from '@/lib/exportHistory'; /** Resolves the react-pdf font name for a brand font (falls back to Helvetica). */ function resolvePdfFontFamily(font: BrandSettings['fontFamily'] | undefined): string { @@ -160,6 +167,36 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) const [showEmailInput, setShowEmailInput] = useState(false); const [emailValue, setEmailValue] = useState(''); const [emailStatus, setEmailStatus] = useState<{ ok: boolean; message: string } | null>(null); + const [exportFormat, setExportFormat] = useState('json'); + const [customTemplate, setCustomTemplate] = useState('Invoice {{id}}\nTotal: {{total}} {{token}}\nStatus: {{status}}'); + const [scheduledAt, setScheduledAt] = useState(''); + const [formatLoading, setFormatLoading] = useState(false); + const [exportMessage, setExportMessage] = useState(null); + + useEffect(() => { + const processDueExports = () => { + const now = Date.now(); + for (const job of getScheduledInvoiceExports()) { + if (job.status !== 'pending' || new Date(job.scheduledAt).getTime() > now) continue; + const blob = new Blob([job.content], { type: job.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `invoice-${job.invoiceId}.${job.extension}`; + link.click(); + URL.revokeObjectURL(url); + completeScheduledInvoiceExport(job.id); + addExportHistoryEntry({ format: job.format, filters: { invoiceId: job.invoiceId, scheduled: true }, recordCount: 1, status: 'completed' }); + } + }; + processDueExports(); + const interval = window.setInterval(processDueExports, 15000); + window.addEventListener('focus', processDueExports); + return () => { + window.clearInterval(interval); + window.removeEventListener('focus', processDueExports); + }; + }, []); const resolveBranding = useCallback(async (): Promise => { if (branding) return branding; @@ -323,11 +360,59 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) a.download = `invoice-${invoice.id}-${formattedDate}.pdf`; a.click(); URL.revokeObjectURL(url); + addExportHistoryEntry({ format: 'pdf', filters: { invoiceId: invoice.id }, recordCount: 1, status: 'completed' }); } finally { setLoading(false); } }, [invoice, total, resolveBranding]); + const downloadInvoiceFormat = useCallback((content: string, mimeType: string, extension: string) => { + const url = URL.createObjectURL(new Blob([content], { type: mimeType })); + const link = document.createElement('a'); + link.href = url; + link.download = `invoice-${invoice.id}.${extension}`; + link.click(); + URL.revokeObjectURL(url); + }, [invoice.id]); + + const handleFormatExport = useCallback(() => { + setFormatLoading(true); + setExportMessage(null); + try { + const result = generateInvoiceExport(invoice, total, exportFormat, customTemplate); + downloadInvoiceFormat(result.content, result.mimeType, result.extension); + addExportHistoryEntry({ format: exportFormat, filters: { invoiceId: invoice.id }, recordCount: 1, status: 'completed' }); + } catch (error) { + setExportMessage(error instanceof Error ? error.message : 'Export failed.'); + } finally { + setFormatLoading(false); + } + }, [customTemplate, downloadInvoiceFormat, exportFormat, invoice, total]); + + const handleScheduleExport = useCallback(() => { + const date = new Date(scheduledAt); + if (!scheduledAt || Number.isNaN(date.getTime()) || date.getTime() <= Date.now()) { + setExportMessage('Choose a future date and time.'); + return; + } + try { + const result = generateInvoiceExport(invoice, total, exportFormat, customTemplate); + scheduleInvoiceExport({ + invoiceId: invoice.id, + format: exportFormat, + scheduledAt: date.toISOString(), + content: result.content, + mimeType: result.mimeType, + extension: result.extension, + }); + addExportHistoryEntry({ format: exportFormat, filters: { invoiceId: invoice.id, scheduled: true }, recordCount: 1, status: 'pending' }); + setExportMessage(`Export scheduled for ${date.toLocaleString()}.`); + setScheduledAt(''); + } catch (error) { + setExportMessage(error instanceof Error ? error.message : 'Could not schedule export.'); + } + }, [customTemplate, exportFormat, invoice, scheduledAt, total]); + const handleReceiptDownload = useCallback(async () => { setReceiptLoading(true); try { @@ -384,6 +469,22 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) )} + + + + {/* Receipt PDF download (compact A5) */}