diff --git a/src/app/api/purchase-orders/process-batch/route.ts b/src/app/api/purchase-orders/process-batch/route.ts index 61f603e..00fb46d 100644 --- a/src/app/api/purchase-orders/process-batch/route.ts +++ b/src/app/api/purchase-orders/process-batch/route.ts @@ -137,7 +137,7 @@ export async function POST(request: Request) { batch_id: batchId, err: String(err), }); - return NextResponse.json({ error: "lookup_failed" }, { status: 500 }); + return NextResponse.json({ error: "lookup_failed", step: "get_batch", detail: String(err).slice(0, 400) }, { status: 500 }); } if (batch.status !== "processing") { @@ -173,7 +173,7 @@ export async function POST(request: Request) { } } catch (err) { console.error("[po-process] dedup lookup failed", { err: String(err) }); - return NextResponse.json({ error: "lookup_failed" }, { status: 500 }); + return NextResponse.json({ error: "lookup_failed", step: "dedup", detail: String(err).slice(0, 400) }, { status: 500 }); } // 5. Site matching @@ -185,7 +185,7 @@ export async function POST(request: Request) { if (m.warning) warnings.push(m.warning); } catch (err) { console.error("[po-process] matchSite failed", { err: String(err) }); - return NextResponse.json({ error: "lookup_failed" }, { status: 500 }); + return NextResponse.json({ error: "lookup_failed", step: "match_site", detail: String(err).slice(0, 400) }, { status: 500 }); } // 6. Move file: read the batch's file URL and wrap as File diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 73844bc..d57a790 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -14,7 +14,6 @@ import { Badge } from "@/components/ui/badge"; import type { EmployeeRecord, FixedCostRecord, - PlantRecord, SiteRecord, VehicleRecord, } from "@/lib/pb-types"; @@ -29,7 +28,19 @@ async function loadOverview() { pb.collection("sites").getList(1, 50, { sort: "name" }), pb.collection("employees").getList(1, 1), pb.collection("vehicles").getList(1, 1), - pb.collection("plant").getList(1, 1), + // Plant now lives in the VEHICLES collection - same query the + // dedicated /dashboard/plant page uses. The `plant` collection was + // removed in the merge, so asking PB for it 404s with "Missing + // collection context". + // PB-SDK auto-cancels in-flight requests sharing the same + // (method + URL-path) key. The bare vehicles call above already + // holds the `GET/api/collections/vehicles/records` slot, so this + // second call must opt out or it aborts the first one and the + // whole Promise.all rejects with AbortError. + pb.collection("vehicles").getList(1, 1, { + filter: "cost_per_hour>0", + $autoCancel: false, + }), pb.collection("fixed_costs").getList(1, 500), pb.collection("purchase_orders").getList(1, 1, { filter: "is_verified=false", diff --git a/src/components/upload-purchase-order-button.tsx b/src/components/upload-purchase-order-button.tsx index 1522af0..d14029e 100644 --- a/src/components/upload-purchase-order-button.tsx +++ b/src/components/upload-purchase-order-button.tsx @@ -23,12 +23,36 @@ import { import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; import type { BatchStatus } from "@/lib/pb-types"; -type Phase = "idle" | "uploading" | "done" | "error"; +type Phase = "idle" | "uploading" | "done"; -interface UploadAck { - batch_id: string; - status: "queued"; - filename: string; +const POLL_INTERVAL_MS = 2000; +// Consecutive failed status polls before the pill admits the server is unreachable. +const POLL_FAILURE_THRESHOLD = 3; +const ACCEPT_ATTR = [...ALLOWED_MIME].join(","); +// Cap on a single drop so a fat-finger multi-select doesn't fire a hundred +// parallel POSTs. The cron in n8n can chew through any number of batches - +// this is purely a UI guardrail. +const MAX_FILES_PER_DROP = 20; + +// Per-file state. Lifecycle: +// pick -> uploadKind:"uploading" -> uploadKind:"queued" | "duplicate" | "error" +// on "queued": poll status endpoint until terminal +// on "duplicate" / "error": terminal - show pill + (for "error") retry +type UploadKind = "uploading" | "queued" | "duplicate" | "error"; + +interface FileRow { + uid: string; + file: File; + uploadKind: UploadKind; + batchId: string | null; + uploadError: string | null; + duplicatePoId: string | null; + // Poll-driven fields - only meaningful once uploadKind === "queued". + status: BatchStatus | null; + batchError: string | null; + processedId: string | null; + pollFailures: number; + retrying: boolean; } interface BatchStatusResponse { @@ -40,24 +64,42 @@ interface BatchStatusResponse { processed_at: string | null; } -const POLL_INTERVAL_MS = 2000; -// Consecutive failed status polls before the pill admits the server is unreachable. -const POLL_FAILURE_THRESHOLD = 3; -const ACCEPT_ATTR = [...ALLOWED_MIME].join(","); +function newRow(file: File): FileRow { + const uid = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return { + uid, + file, + uploadKind: "uploading", + batchId: null, + uploadError: null, + duplicatePoId: null, + status: null, + batchError: null, + processedId: null, + pollFailures: 0, + retrying: false, + }; +} + +function isTerminalBatch(s: BatchStatus): boolean { + return s === "processed" || s === "failed" || s === "dead"; +} export function UploadPurchaseOrderButton() { const [open, setOpen] = React.useState(false); - const [file, setFile] = React.useState(null); + const [files, setFiles] = React.useState([]); const [phase, setPhase] = React.useState("idle"); - const [result, setResult] = React.useState(null); + const [rows, setRows] = React.useState([]); const [errorMsg, setErrorMsg] = React.useState(null); - const [batchStatus, setBatchStatus] = React.useState(null); - const [batchError, setBatchError] = React.useState(null); - const [batchProcessedId, setBatchProcessedId] = React.useState(null); - const [retrying, setRetrying] = React.useState(false); - const [pollFailures, setPollFailures] = React.useState(0); const inputRef = React.useRef(null); const pollRef = React.useRef | null>(null); + // Latest-rows ref so the poll callback (which only re-binds on `rows` + // change) can read the current snapshot without stale closures. + const rowsRef = React.useRef([]); + rowsRef.current = rows; const stopPolling = React.useCallback(() => { if (pollRef.current) { @@ -68,15 +110,10 @@ export function UploadPurchaseOrderButton() { const reset = React.useCallback(() => { stopPolling(); - setFile(null); + setFiles([]); setPhase("idle"); - setResult(null); + setRows([]); setErrorMsg(null); - setBatchStatus(null); - setBatchError(null); - setBatchProcessedId(null); - setRetrying(false); - setPollFailures(0); if (inputRef.current) inputRef.current.value = ""; }, [stopPolling]); @@ -86,109 +123,287 @@ export function UploadPurchaseOrderButton() { setTimeout(reset, 200); }; - // Poll the batch status endpoint while the import is in a non-terminal - // state. The interval self-tears on terminal states (processed/failed/dead); - // the cleanup tears on unmount or dependency change. + // One poll loop for every queued row. Each tick, fetch every row's batch + // status in parallel. The interval self-tears once ALL queued rows are in + // a terminal batch state; the cleanup tears on unmount or rows change. React.useEffect(() => { - if (!result || !batchStatus) return; - if ( - batchStatus === "processed" || - batchStatus === "failed" || - batchStatus === "dead" - ) { + const queued = rows.filter( + (r) => r.uploadKind === "queued" && r.batchId !== null, + ); + const anyOpen = queued.some( + (r) => r.status === null || !isTerminalBatch(r.status), + ); + if (!anyOpen) { stopPolling(); return; } pollRef.current = setInterval(async () => { - try { - const r = await fetch( - `/api/data-import-batches/${result.batch_id}/status`, - ); - if (!r.ok) return; - const data: BatchStatusResponse = await r.json(); - setPollFailures(0); - setBatchStatus(data.status); - setBatchError(data.last_error); - if (data.processed_record_id) { - setBatchProcessedId(data.processed_record_id); - } - } catch { - // Network blip — keep polling, but surface it after a few misses. - setPollFailures((n) => n + 1); - } + const queuedNow = rowsRef.current.filter( + (r) => r.uploadKind === "queued" && r.batchId !== null, + ); + await Promise.all( + queuedNow.map(async (r) => { + try { + const res = await fetch( + `/api/data-import-batches/${r.batchId}/status`, + ); + if (!res.ok) return; + const data: BatchStatusResponse = await res.json(); + setRows((prev) => + prev.map((row) => + row.uid !== r.uid + ? row + : { + ...row, + pollFailures: 0, + status: data.status, + batchError: data.last_error, + processedId: + data.processed_record_id ?? row.processedId, + }, + ), + ); + } catch { + setRows((prev) => + prev.map((row) => + row.uid !== r.uid + ? row + : { ...row, pollFailures: row.pollFailures + 1 }, + ), + ); + } + }), + ); }, POLL_INTERVAL_MS); return () => { stopPolling(); }; - }, [result, batchStatus, stopPolling]); + }, [rows, stopPolling]); const submit = async () => { - if (!file || phase === "uploading") return; + if (files.length === 0 || phase === "uploading") return; + + // Snapshot the files we're about to upload so a re-pick mid-upload + // can't mutate the in-flight set out from under us. + const toUpload = files.slice(); + const initial = toUpload.map(newRow); + setRows(initial); setPhase("uploading"); setErrorMsg(null); + + // Fire all uploads in parallel. Each promise resolves to the final + // state for its row; we replace the row set once everything settles. + const settled = await Promise.all( + toUpload.map(async (file, idx): Promise => { + const row = initial[idx]; + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/purchase-orders/upload", { + method: "POST", + body: form, + }); + const data = await res.json(); + if (res.status === 202 && data.batch_id) { + return { + ...row, + uploadKind: "queued", + batchId: data.batch_id, + status: "pending", + }; + } + if (res.status === 200 && data.duplicate) { + return { + ...row, + uploadKind: "duplicate", + duplicatePoId: data.existing_po_id ?? null, + }; + } + return { + ...row, + uploadKind: "error", + uploadError: + typeof data.error === "string" ? data.error : "Upload failed", + }; + } catch (e) { + return { + ...row, + uploadKind: "error", + uploadError: e instanceof Error ? e.message : "Upload failed", + }; + } + }), + ); + setRows(settled); + setPhase("done"); + }; + + // Retry an upload that never made it onto a batch row (HTTP error, blob + // too big, server 5xx, etc). Re-sends the same bytes - the route dedups + // by hash, so an accidental double-retry is harmless. + const handleUploadRetry = async (uid: string) => { + const row = rowsRef.current.find((r) => r.uid === uid); + if (!row || row.uploadKind !== "error" || row.retrying) return; + setRows((prev) => + prev.map((r) => (r.uid === uid ? { ...r, retrying: true } : r)), + ); try { const form = new FormData(); - form.append("file", file); + form.append("file", row.file); const res = await fetch("/api/purchase-orders/upload", { method: "POST", body: form, }); const data = await res.json(); - if (!res.ok) { - setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed"); - setPhase("error"); - return; + if (res.status === 202 && data.batch_id) { + setRows((prev) => + prev.map((r) => + r.uid !== uid + ? r + : { + ...r, + uploadKind: "queued", + batchId: data.batch_id, + uploadError: null, + status: "pending", + batchError: null, + processedId: null, + pollFailures: 0, + retrying: false, + }, + ), + ); + } else if (res.status === 200 && data.duplicate) { + setRows((prev) => + prev.map((r) => + r.uid !== uid + ? r + : { + ...r, + uploadKind: "duplicate", + duplicatePoId: data.existing_po_id ?? null, + uploadError: null, + retrying: false, + }, + ), + ); + } else { + setRows((prev) => + prev.map((r) => + r.uid !== uid + ? r + : { + ...r, + uploadError: + typeof data.error === "string" + ? data.error + : "Retry failed", + retrying: false, + }, + ), + ); } - // 202 returning { batch_id, status: "queued", filename } - setResult(data as UploadAck); - setBatchStatus("pending"); - setBatchError(null); - setBatchProcessedId(null); - setPhase("done"); } catch (e) { - setErrorMsg(e instanceof Error ? e.message : "Upload failed"); - setPhase("error"); + setRows((prev) => + prev.map((r) => + r.uid !== uid + ? r + : { + ...r, + uploadError: e instanceof Error ? e.message : "Retry failed", + retrying: false, + }, + ), + ); } }; - const handleRetry = async () => { - if (!result || retrying) return; - setRetrying(true); + // Retry a batch that made it onto a row but the cron couldn't process + // (typically failed -> attempt 3 -> dead). Hits the dedicated retry + // endpoint which resets status=pending and attempts=0. + const handlePollRetry = async (uid: string) => { + const row = rowsRef.current.find((r) => r.uid === uid); + if (!row || !row.batchId || row.uploadKind !== "queued" || row.retrying) { + return; + } + setRows((prev) => + prev.map((r) => (r.uid === uid ? { ...r, retrying: true } : r)), + ); try { - const r = await fetch( - `/api/data-import-batches/${result.batch_id}/retry`, + const res = await fetch( + `/api/data-import-batches/${row.batchId}/retry`, { method: "POST" }, ); - if (r.ok) { - setBatchStatus("pending"); - setBatchError(null); + if (res.ok) { + setRows((prev) => + prev.map((r) => + r.uid !== uid + ? r + : { + ...r, + status: "pending", + batchError: null, + pollFailures: 0, + retrying: false, + }, + ), + ); + } else { + setRows((prev) => + prev.map((r) => (r.uid === uid ? { ...r, retrying: false } : r)), + ); } } catch { - /* leave the user to retry again */ - } finally { - setRetrying(false); + setRows((prev) => + prev.map((r) => (r.uid === uid ? { ...r, retrying: false } : r)), + ); } }; - const onPickFile: React.ChangeEventHandler = (e) => { - const f = e.target.files?.[0]; - if (!f) return; - if (!ALLOWED_MIME.has(f.type)) { - setErrorMsg("Unsupported file type: " + (f.type || "unknown")); - setFile(null); - return; + const onPickFiles: React.ChangeEventHandler = (e) => { + const picked = e.target.files; + if (!picked || picked.length === 0) return; + + const errors: string[] = []; + const accepted: File[] = []; + Array.from(picked).forEach((f) => { + if (f.size === 0) { + errors.push(`${f.name}: empty file`); + return; + } + if (f.size > MAX_FILE_BYTES) { + errors.push(`${f.name}: too large`); + return; + } + if (!ALLOWED_MIME.has(f.type)) { + errors.push(`${f.name}: unsupported type`); + return; + } + accepted.push(f); + }); + + let truncated = false; + if (accepted.length > MAX_FILES_PER_DROP) { + truncated = true; + accepted.length = MAX_FILES_PER_DROP; } - if (f.size > MAX_FILE_BYTES) { - setErrorMsg("File too large — max 20 MB"); - setFile(null); - return; + if (truncated) { + errors.push( + `Only the first ${MAX_FILES_PER_DROP} files will be uploaded (you picked ${picked.length}).`, + ); } - setErrorMsg(null); - setFile(f); + + setErrorMsg(errors.length > 0 ? errors.join(" - ") : null); + setFiles(accepted); }; - const canRetry = - batchStatus === "failed" || batchStatus === "dead"; + const canSubmit = files.length > 0 && phase !== "uploading"; + + const isDegraded = (row: FileRow) => + row.pollFailures >= POLL_FAILURE_THRESHOLD; + const canPollRetry = (row: FileRow) => + row.uploadKind === "queued" && + (row.status === "failed" || row.status === "dead"); return ( <> @@ -205,21 +420,19 @@ export function UploadPurchaseOrderButton() { Upload purchase order - Attach the PO document. The pipeline will extract the header - fields and line items, then add the record to your purchase order list. + Attach one or more PO documents. The pipeline will extract + the header fields and line items, then add each record to + your purchase order list. - {phase === "done" && result ? ( + {phase === "done" && rows.length > 0 ? ( = POLL_FAILURE_THRESHOLD} - canRetry={canRetry} - retrying={retrying} - onRetry={handleRetry} + rows={rows} + isDegraded={isDegraded} + canPollRetry={canPollRetry} + onUploadRetry={handleUploadRetry} + onPollRetry={handlePollRetry} onClose={close} /> ) : ( @@ -230,35 +443,49 @@ export function UploadPurchaseOrderButton() { type="file" accept={ACCEPT_ATTR} className="sr-only" - onChange={onPickFile} + multiple + onChange={onPickFiles} /> - {file ? ( + {files.length > 0 ? ( <> -

{file.name}

-

- {(file.size / 1024).toFixed(1)} KB - {file.type} +

+ {files.length} file{files.length === 1 ? "" : "s"} ready

+
    + {files.map((f) => ( +
  • + + {f.name} + + {(f.size / 1024).toFixed(1)} KB +
  • + ))} +
) : ( <>

- Click to choose a PO document + Click to choose PO document(s)

- JPG, PNG, WebP, or PDF - up to 20 MB + JPG, PNG, WebP, or PDF - up to 20 MB each, max{" "} + {MAX_FILES_PER_DROP} files

)} @@ -272,7 +499,8 @@ export function UploadPurchaseOrderButton() {

- Records start as unverified - approve from the PO row after review. + Records start as unverified{" "} + - approve from the PO row after review.

@@ -305,65 +535,197 @@ export function UploadPurchaseOrderButton() { ); } -function DonePanel({ - result, - batchStatus, - batchError, - batchProcessedId, - pollDegraded, - canRetry, - retrying, - onRetry, - onClose, -}: { - result: UploadAck; - batchStatus: BatchStatus | null; - batchError: string | null; - batchProcessedId: string | null; - pollDegraded: boolean; - canRetry: boolean; - retrying: boolean; - onRetry: () => void; +interface DonePanelProps { + rows: FileRow[]; + isDegraded: (row: FileRow) => boolean; + canPollRetry: (row: FileRow) => boolean; + onUploadRetry: (uid: string) => void; + onPollRetry: (uid: string) => void; onClose: () => void; -}) { +} + +function DonePanel({ + rows, + isDegraded, + canPollRetry, + onUploadRetry, + onPollRetry, + onClose, +}: DonePanelProps) { + const queued = rows.filter((r) => r.uploadKind === "queued").length; + const duplicates = rows.filter((r) => r.uploadKind === "duplicate").length; + const errors = rows.filter((r) => r.uploadKind === "error").length; + const summary = + `${rows.length} file${rows.length === 1 ? "" : "s"}: ` + + `${queued} queued, ${duplicates} duplicate${duplicates === 1 ? "" : "s"}, ${errors} failed.`; + return (

Uploaded - queued for processing

-

- {result.filename}{" "} - has been received. The pipeline will pick it up on the next tick. -

- {batchStatus && ( - - )} +

{summary}

-
- +
    + {rows.map((r) => ( +
  • + onUploadRetry(r.uid)} + onPollRetry={() => onPollRetry(r.uid)} + /> +
  • + ))} +
+
); } +interface RowPanelProps { + row: FileRow; + degraded: boolean; + canPollRetry: boolean; + onUploadRetry: () => void; + onPollRetry: () => void; +} + +function RowPanel({ + row, + degraded, + canPollRetry, + onUploadRetry, + onPollRetry, +}: RowPanelProps) { + return ( +
+
+
+

+ {row.file.name} +

+

+ {(row.file.size / 1024).toFixed(1)} KB - {row.file.type || "unknown"} +

+
+ +
+ {(row.uploadKind === "error" || canPollRetry) && ( +
+ {row.uploadKind === "error" && ( + + )} + {canPollRetry && ( + + )} +
+ )} + {row.uploadKind === "error" && row.uploadError && ( +

+ {row.uploadError} +

+ )} +
+ ); +} + +function UploadRowPill({ + row, + degraded, +}: { + row: FileRow; + degraded: boolean; +}) { + if (row.uploadKind === "uploading") { + return ( + + + Uploading + + ); + } + if (row.uploadKind === "duplicate") { + return ( + + + Duplicate + + ); + } + if (row.uploadKind === "error") { + return ( + + + Failed + + ); + } + // queued - show poll-derived pill + return ( + + ); +} + const PILL_BASE = "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium"; @@ -387,11 +749,29 @@ function BatchStatusPill({ processedId, degraded, }: { - status: BatchStatus; + status: BatchStatus | null; error: string | null; processedId: string | null; degraded: boolean; }) { + // Pre-first-poll: show "Queued" without a sub-line. The richer + // presentation below is keyed off a real status from the server. + if (status === null) { + return ( +

+ + + {degraded ? "Can't reach server - retrying…" : "Queued"} + +

+ ); + } + const retryHint = error ?? "Click retry to try again."; // Keyed by the BatchStatus union: adding a status to the union without a @@ -442,14 +822,16 @@ function BatchStatusPill({

{icon} - {degraded ? "Can't reach server — retrying…" : label} + {degraded ? "Can't reach server - retrying…" : label} - {!degraded && sub && {sub}} + {!degraded && sub && ( + {sub} + )}

); } diff --git a/src/lib/pb.ts b/src/lib/pb.ts index 78778fa..26a0b62 100644 --- a/src/lib/pb.ts +++ b/src/lib/pb.ts @@ -75,6 +75,21 @@ export async function pbAdmin(): Promise { } const client = new PocketBase(PB_URL); + // Disable SDK auto-cancellation on the admin client. Auto-cancel is a + // browser feature (cancels in-flight requests when the user navigates + // away) keyed off (method + URL-path). On the server, the module-level + // client cache means every concurrent route handler shares ONE client + // instance, so the second concurrent request to the same endpoint + // aborts the first one and we get cascading AbortError 500s - visible + // as soon as anything fires parallel requests (e.g. multi-file upload, + // or a Promise.all over PB collections). Server-side requests have + // nothing to navigate away from, so disabling is safe. + // + // The field is `private` in the public .d.ts even though the runtime + // supports it. The double-cast (object -> never -> our shape) is the + // only way to write to a `private` field on a class outside that class + // without `any`. + (client as unknown as { enableAutoCancellation: boolean }).enableAutoCancellation = false; if (presetToken) { client.authStore.save(presetToken, null); } else {