From d596cbe836596b1ee30b6de684de44fc915f9ef0 Mon Sep 17 00:00:00 2001 From: Andymick Date: Thu, 16 Jul 2026 21:14:49 +0100 Subject: [PATCH] Add automatic invoice parsing to extract purchase items - Install pdf-parse and tesseract.js for PDF/image processing - Create /api/admin/parse-invoice endpoint to extract line items from invoices - Parser extracts quantity and description from invoice text - Attempts to match extracted items to products in database - Update PurchaseForm with "Extract items" button - Pre-fill purchase form with parsed items (user can edit/add more) - Supports PDF files and image scans (JPG, PNG, etc) Co-Authored-By: Claude Haiku 4.5 --- package-lock.json | 341 +++++++++++++++++++++++ package.json | 2 + src/app/api/admin/parse-invoice/route.ts | 124 +++++++++ src/components/PurchaseForm.tsx | 89 +++++- 4 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 src/app/api/admin/parse-invoice/route.ts diff --git a/package-lock.json b/package-lock.json index 4918767..417117e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,12 @@ "konva": "9.3.14", "next": "14.2.5", "nodemailer": "6.9.14", + "pdf-parse": "^2.4.5", "react": "18.3.1", "react-dom": "18.3.1", "react-konva": "18.2.10", "stripe": "16.2.0", + "tesseract.js": "^7.0.0", "uuid": "9.0.1", "zustand": "4.5.4" }, @@ -481,6 +483,205 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/env": { "version": "14.2.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", @@ -934,6 +1135,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1502,6 +1709,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/its-fine": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", @@ -1730,6 +1943,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -1801,6 +2034,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1808,6 +2050,38 @@ "dev": true, "license": "MIT" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2116,6 +2390,12 @@ "node": ">=8.10.0" } }, + "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/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2440,6 +2720,30 @@ "url": "https://github.com/sponsors/antonk52" } }, + "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/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2524,6 +2828,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2638,6 +2948,28 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -2654,6 +2986,15 @@ "url": "https://github.com/sponsors/eemeli" } }, + "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/zustand": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.4.tgz", diff --git a/package.json b/package.json index 6b6ffeb..83f226e 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "konva": "9.3.14", "next": "14.2.5", "nodemailer": "6.9.14", + "pdf-parse": "^2.4.5", "react": "18.3.1", "react-dom": "18.3.1", "react-konva": "18.2.10", "stripe": "16.2.0", + "tesseract.js": "^7.0.0", "uuid": "9.0.1", "zustand": "4.5.4" }, diff --git a/src/app/api/admin/parse-invoice/route.ts b/src/app/api/admin/parse-invoice/route.ts new file mode 100644 index 0000000..969d563 --- /dev/null +++ b/src/app/api/admin/parse-invoice/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from 'next/server'; +import { isAdminAuthenticated } from '@/lib/adminAuth'; +import { prisma } from '@/lib/prisma'; +import pdfParse from 'pdf-parse'; +import * as Tesseract from 'tesseract.js'; + +type ExtractedItem = { + description: string; + quantity: number; + productId?: string; +}; + +async function extractTextFromPDF(buffer: Buffer): Promise { + try { + const data = await pdfParse(buffer); + return data.text; + } catch (err) { + throw new Error('Failed to extract text from PDF'); + } +} + +async function extractTextFromImage(buffer: Buffer): Promise { + try { + const result = await Tesseract.recognize(buffer, 'eng'); + return result.data.text; + } catch (err) { + throw new Error('Failed to OCR image'); + } +} + +function parseInvoiceText(text: string, products: any[]): ExtractedItem[] { + const items: ExtractedItem[] = []; + const lines = text.split('\n'); + + // Build product search index (lowercase for matching) + const productIndex = new Map(products.map((p) => [p.name.toLowerCase(), p.id])); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.length < 5) continue; + + // Look for patterns like: quantity x product or product x quantity + // Examples: "10 x T-Shirt", "T-Shirt - 10", "10x hoodies" + const matches = trimmed.match(/(\d+)\s*x?\s*(.+?)(?:\s*-\s*£?[\d.]+)?$/i); + if (!matches) continue; + + const quantity = parseInt(matches[1], 10); + let description = matches[2].trim(); + + // Clean up description (remove common noise) + description = description.replace(/\s*-\s*\d+\s*$/, ''); // Remove trailing numbers + description = description.replace(/£[\d.]+/, '').trim(); // Remove prices + + if (quantity > 0 && description.length > 2) { + // Try to match to a product + const lowerDesc = description.toLowerCase(); + let productId: string | undefined; + + for (const [productName, pId] of productIndex) { + if (lowerDesc.includes(productName)) { + productId = pId; + break; + } + } + + items.push({ + description, + quantity, + productId, + }); + } + } + + return items; +} + +export async function POST(req: Request) { + const isAdmin = await isAdminAuthenticated(); + if (!isAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const formData = await req.formData(); + const file = formData.get('invoice') as File; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (file.size > 8 * 1024 * 1024) { + return NextResponse.json({ error: 'File too large (max 8MB)' }, { status: 400 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + let text = ''; + + if (file.type === 'application/pdf') { + text = await extractTextFromPDF(buffer); + } else if (file.type.startsWith('image/')) { + text = await extractTextFromImage(buffer); + } else { + return NextResponse.json( + { error: 'Unsupported file type. Use PDF or image.' }, + { status: 400 } + ); + } + + // Get all products for matching + const products = await prisma.product.findMany({ + select: { id: true, name: true }, + }); + + const extractedItems = parseInvoiceText(text, products); + + return NextResponse.json({ + items: extractedItems, + rawText: text.substring(0, 500), // First 500 chars for debugging + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/PurchaseForm.tsx b/src/components/PurchaseForm.tsx index 0a1df0a..2f3f141 100644 --- a/src/components/PurchaseForm.tsx +++ b/src/components/PurchaseForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { createPurchase } from '@/app/admin/accounts/purchases/actions'; import Price from './Price'; @@ -30,6 +30,9 @@ export default function PurchaseForm({ products }: { products: ProductOption[] } { key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' }, ]); const [nextKey, setNextKey] = useState(2); + const [parsing, setParsing] = useState(false); + const [parseError, setParseError] = useState(null); + const fileInputRef = useRef(null); const updateRow = (key: number, patch: Partial) => { setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r))); @@ -61,6 +64,56 @@ export default function PurchaseForm({ products }: { products: ProductOption[] } setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev)); }; + const parseInvoice = async () => { + if (!fileInputRef.current?.files?.[0]) { + setParseError('No file selected'); + return; + } + + setParsing(true); + setParseError(null); + + try { + const formData = new FormData(); + formData.append('invoice', fileInputRef.current.files[0]); + + const res = await fetch('/api/admin/parse-invoice', { + method: 'POST', + body: formData, + }); + + const data = await res.json(); + + if (!res.ok) { + setParseError(data.error || 'Failed to parse invoice'); + return; + } + + if (!data.items || data.items.length === 0) { + setParseError('No items found in invoice. Please add them manually.'); + return; + } + + // Convert extracted items to form rows + const newRows: Row[] = data.items.map((item: any, index: number) => ({ + key: nextKey + index, + productId: item.productId || '', + description: item.description, + color: '', + size: '', + quantity: item.quantity, + unitCostInput: '', + })); + + setRows(newRows); + setNextKey(nextKey + newRows.length); + } catch (err) { + setParseError(err instanceof Error ? err.message : 'Failed to parse invoice'); + } finally { + setParsing(false); + } + }; + const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100)); const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitCostInput), 0); @@ -113,14 +166,32 @@ export default function PurchaseForm({ products }: { products: ProductOption[] } - -

PDF or photo, up to 8 MB — kept with the purchase for your records.

+
+ + +
+

+ PDF or photo, up to 8 MB. Click "Extract items" to auto-fill line items from the invoice. +

+ {parseError && ( +

+ {parseError} +

+ )}