diff --git a/.npmrc b/.npmrc index c13aad7613..9bfb782bd0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ -onlyBuiltDependenciesFile= -ignore-scripts=false +side-effects-cache=true diff --git a/.pnpmrc.json b/.pnpmrc.json new file mode 100644 index 0000000000..80b128d94f --- /dev/null +++ b/.pnpmrc.json @@ -0,0 +1 @@ +{"pnpm":{"onlyBuiltDependencies":["esbuild","sharp","unrs-resolver"]}} diff --git a/package.json b/package.json index 91ec7a0e06..6ce19ed952 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.3.0", "description": "OpenClaw Mission Control — open-source agent orchestration dashboard", "scripts": { - "dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}", + "dev": "next dev --hostname 0.0.0.0 --port ${PORT:-3000}", "build": "next build", "start": "next start --hostname 0.0.0.0 --port ${PORT:-3000}", "lint": "eslint .", @@ -28,6 +28,7 @@ "eslint-config-next": "^16.1.6", "next": "^16.1.6", "next-themes": "^0.4.6", + "pdf-parse": "^2.4.5", "pino": "^10.3.1", "postcss": "^8.5.2", "react": "^19.0.1", @@ -50,6 +51,7 @@ "@testing-library/react": "^16.1.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.6", + "@types/pdf-parse": "^1.1.5", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@types/ws": "^8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e230b81e82..595a20093f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 pino: specifier: ^10.3.1 version: 10.3.1 @@ -96,6 +99,9 @@ importers: '@types/node': specifier: ^22.10.6 version: 22.19.9 + '@types/pdf-parse': + specifier: ^1.1.5 + version: 1.1.5 '@types/react': specifier: ^19.0.8 version: 19.2.13 @@ -736,6 +742,75 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1322,6 +1397,9 @@ packages: '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} + '@types/pdf-parse@1.1.5': + resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3341,6 +3419,15 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4954,6 +5041,49 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + 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 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -5793,6 +5923,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pdf-parse@1.1.5': + dependencies: + '@types/node': 22.19.9 + '@types/react-dom@19.2.3(@types/react@19.2.13)': dependencies: '@types/react': 19.2.13 @@ -8241,6 +8375,15 @@ snapshots: pathval@2.0.1: {} + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + picocolors@1.1.1: {} picomatch@2.3.1: {} diff --git a/src/app/api/batch-codes/expiring/route.ts b/src/app/api/batch-codes/expiring/route.ts new file mode 100644 index 0000000000..855dcae3d8 --- /dev/null +++ b/src/app/api/batch-codes/expiring/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const db = getDatabase() + + const { searchParams } = new URL(request.url) + const months = parseInt(searchParams.get('months') || '12') + + // Calculate future date + const futureDate = new Date() + futureDate.setMonth(futureDate.getMonth() + months) + + const batches = db.prepare(` + SELECT * FROM batch_codes + WHERE status = 'active' + AND expiry_date <= ? + ORDER BY expiry_date ASC + `).all(futureDate.toISOString().split('T')[0]) + + return NextResponse.json({ batches }) +} \ No newline at end of file diff --git a/src/app/api/batch-codes/route.ts b/src/app/api/batch-codes/route.ts new file mode 100644 index 0000000000..67fefd876f --- /dev/null +++ b/src/app/api/batch-codes/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const db = getDatabase() + + const { searchParams } = new URL(request.url) + const query = searchParams.get('q') || '' + const status = searchParams.get('status') + const expiryFilter = searchParams.get('expiryWithinMonths') + + let sql = 'SELECT * FROM batch_codes WHERE 1=1' + const params: any[] = [] + + if (query) { + sql += ' AND (product_code LIKE ? OR product_description LIKE ? OR batch_code LIKE ?)' + const q = `%${query}%` + params.push(q, q, q) + } + + if (status) { + sql += ' AND status = ?' + params.push(status) + } + + if (expiryFilter) { + const months = parseInt(expiryFilter) + const futureDate = new Date() + futureDate.setMonth(futureDate.getMonth() + months) + sql += ' AND expiry_date <= ? AND status = ?' + params.push(futureDate.toISOString().split('T')[0], 'active') + } + + sql += ' ORDER BY uploaded_at DESC' + + const batches = db.prepare(sql).all(...params) + + return NextResponse.json({ batches }) +} + +export async function PATCH(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const db = getDatabase() + + const body = await request.json() + const { id, status } = body + + if (!id || !status) { + return NextResponse.json({ error: 'Missing id or status' }, { status: 400 }) + } + + const retiredAt = status === 'retired' ? new Date().toISOString() : null + + db.prepare(` + UPDATE batch_codes + SET status = ?, retired_at = ? + WHERE id = ? + `).run(status, retiredAt, id) + + return NextResponse.json({ success: true }) +} \ No newline at end of file diff --git a/src/app/api/batch-codes/upload/route.ts b/src/app/api/batch-codes/upload/route.ts new file mode 100644 index 0000000000..89b7884691 --- /dev/null +++ b/src/app/api/batch-codes/upload/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; +import { writeFile, mkdir } from 'fs/promises'; +import path from 'path'; + +// PDF parsing +async function parseDeliveryNotePDF(buffer: Buffer): Promise { + const pdfParse = require('pdf-parse') + const data = await pdfParse(buffer) + const text = data.text + + // Extract lines that look like delivery note rows + // Format: Product Code | Description | Batch Code | Expiry Date + const lines = text.split('\n') + const batches: any[] = [] + + // Skip header rows - look for data rows with pattern + for (const line of lines) { + // Skip empty or header-like lines + if (!line.trim() || line.toLowerCase().includes('product') && line.toLowerCase().includes('description')) { + continue + } + + // Try to extract fields from line - assume pipe or space separated + const parts = line.trim().split(/\s{2,}|\|/).map((p: string) => p.trim()).filter(Boolean) + + if (parts.length >= 4) { + const productCode = parts[0] + const productDescription = parts[1] + const batchCode = parts[2] + const expiryDateStr = parts[3] + + // Validate batch code looks like a batch (alphanumeric) + if (batchCode && /^[A-Za-z0-9\-]+$/.test(batchCode) && productCode) { + // Parse expiry date (could be DD/MM/YYYY or YYYY-MM-DD) + let expiryDate = expiryDateStr + try { + const dateParts = expiryDateStr.split(/[-/]/) + if (dateParts.length === 3) { + const day = parseInt(dateParts[0]) + const month = parseInt(dateParts[1]) + const year = parseInt(dateParts[2]) + // Assume YYYY if year > 100, otherwise DD/MM/YY or DD/MM/YYYY + const fullYear = year < 100 ? 2000 + year : year + expiryDate = `${fullYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + } + } catch (e) { + // Use as-is if parsing fails + } + + batches.push({ + product_code: productCode, + product_description: productDescription, + batch_code: batchCode, + expiry_date: expiryDate + }) + } + } + } + + return batches +} + +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + // Validate it's a PDF + if (!file.name.toLowerCase().endsWith('.pdf')) { + return NextResponse.json({ error: 'Only PDF files are accepted' }, { status: 400 }) + } + + // Read file buffer + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // Parse PDF + let batches: any[] + try { + batches = await parseDeliveryNotePDF(buffer) + } catch (error) { + console.error('PDF parsing error:', error) + return NextResponse.json({ error: 'Failed to parse PDF' }, { status: 500 }) + } + + if (batches.length === 0) { + return NextResponse.json({ error: 'No valid batch data found in PDF' }, { status: 400 }) + } + + // Save to database + const db = getDatabase() + const deliveryDate = new Date().toISOString().split('T')[0] + + const insertedIds: string[] = [] + + for (const batch of batches) { + const id = `${batch.batch_code}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + + db.prepare(` + INSERT INTO batch_codes (id, product_code, product_description, batch_code, expiry_date, status, delivery_note, delivery_date) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?) + `).run( + id, + batch.product_code, + batch.product_description, + batch.batch_code, + batch.expiry_date, + file.name, + deliveryDate + ) + + insertedIds.push(id) + } + + return NextResponse.json({ + success: true, + count: insertedIds.length, + batches: insertedIds + }) +} \ No newline at end of file diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 548d45987a..4a23ea3ec3 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -830,6 +830,29 @@ const migrations: Migration[] = [ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_expires_at ON agent_api_keys(expires_at)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_revoked_at ON agent_api_keys(revoked_at)`) } + }, + { + id: '028_batch_codes', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS batch_codes ( + id TEXT PRIMARY KEY, + product_code TEXT NOT NULL, + product_description TEXT NOT NULL, + batch_code TEXT NOT NULL, + expiry_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + delivery_note TEXT NOT NULL, + delivery_date TEXT NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + retired_at TEXT + ); + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_codes_batch_code ON batch_codes(batch_code)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_codes_product_code ON batch_codes(product_code)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_codes_expiry_date ON batch_codes(expiry_date)`) + db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_codes_status ON batch_codes(status)`) + } } ]