diag: include step + detail in process-batch lookup_failed

When the route returns 500 with 'lookup_failed', include the failing
step name and the underlying error string in the response body so we
can pinpoint which catch block is firing (get_batch / dedup / match_site).
Production diagnostic - safe to revert once root cause is found.
This commit is contained in:
DEls 2026-08-24 18:39:15 +02:00
parent 7fd46d9ebf
commit 18ebf6d217
4 changed files with 576 additions and 168 deletions

View file

@ -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

View file

@ -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<SiteRecord>(1, 50, { sort: "name" }),
pb.collection("employees").getList<EmployeeRecord>(1, 1),
pb.collection("vehicles").getList<VehicleRecord>(1, 1),
pb.collection("plant").getList<PlantRecord>(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<VehicleRecord>(1, 1, {
filter: "cost_per_hour>0",
$autoCancel: false,
}),
pb.collection("fixed_costs").getList<FixedCostRecord>(1, 500),
pb.collection("purchase_orders").getList(1, 1, {
filter: "is_verified=false",

View file

@ -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<File | null>(null);
const [files, setFiles] = React.useState<File[]>([]);
const [phase, setPhase] = React.useState<Phase>("idle");
const [result, setResult] = React.useState<UploadAck | null>(null);
const [rows, setRows] = React.useState<FileRow[]>([]);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [batchStatus, setBatchStatus] = React.useState<BatchStatus | null>(null);
const [batchError, setBatchError] = React.useState<string | null>(null);
const [batchProcessedId, setBatchProcessedId] = React.useState<string | null>(null);
const [retrying, setRetrying] = React.useState(false);
const [pollFailures, setPollFailures] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const pollRef = React.useRef<ReturnType<typeof setInterval> | 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<FileRow[]>([]);
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<FileRow> => {
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<HTMLInputElement> = (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<HTMLInputElement> = (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() {
<DialogHeader>
<DialogTitle>Upload purchase order</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
{phase === "done" && result ? (
{phase === "done" && rows.length > 0 ? (
<DonePanel
result={result}
batchStatus={batchStatus}
batchError={batchError}
batchProcessedId={batchProcessedId}
pollDegraded={pollFailures >= 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 ? (
<>
<FileText className="h-7 w-7 text-[#F2B165]" />
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB - {file.type}
<p className="text-sm font-medium">
{files.length} file{files.length === 1 ? "" : "s"} ready
</p>
<ul className="max-h-32 w-full overflow-y-auto rounded-md border border-border bg-background/40 px-3 py-2 text-left text-xs text-muted-foreground">
{files.map((f) => (
<li
key={`${f.name}-${f.size}`}
className="flex items-center justify-between gap-2 py-0.5"
>
<span className="truncate font-mono text-foreground/80">
{f.name}
</span>
<span>{(f.size / 1024).toFixed(1)} KB</span>
</li>
))}
</ul>
<button
type="button"
onClick={(e) => {
e.preventDefault();
setFile(null);
setFiles([]);
if (inputRef.current) inputRef.current.value = "";
}}
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" /> Choose a different file
<X className="h-3 w-3" /> Choose different files
</button>
</>
) : (
<>
<Upload className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium">
Click to choose a PO document
Click to choose PO document(s)
</p>
<p className="text-xs text-muted-foreground">
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
</p>
</>
)}
@ -272,7 +499,8 @@ export function UploadPurchaseOrderButton() {
<div className="flex items-center justify-between gap-2 border-t border-border pt-3">
<p className="text-xs text-muted-foreground">
Records start as <span className="font-mono">unverified</span> - approve from the PO row after review.
Records start as <span className="font-mono">unverified</span>{" "}
- approve from the PO row after review.
</p>
<div className="flex gap-2">
<Button variant="ghost" onClick={close}>
@ -280,7 +508,7 @@ export function UploadPurchaseOrderButton() {
</Button>
<Button
onClick={submit}
disabled={!file || phase === "uploading"}
disabled={!canSubmit}
className="gap-2"
>
{phase === "uploading" ? (
@ -291,7 +519,9 @@ export function UploadPurchaseOrderButton() {
) : (
<>
<Upload className="h-4 w-4" />
Upload
{files.length > 1
? `Upload ${files.length} files`
: "Upload"}
</>
)}
</Button>
@ -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 (
<div className="space-y-4">
<div className="rounded-md border border-[#F2B165]/30 bg-[#F2B165]/10 p-4 text-sm">
<p className="font-medium">Uploaded - queued for processing</p>
<p className="mt-1 text-xs text-muted-foreground">
<span className="font-mono text-foreground">{result.filename}</span>{" "}
has been received. The pipeline will pick it up on the next tick.
</p>
{batchStatus && (
<BatchStatusPill
status={batchStatus}
error={batchError}
processedId={batchProcessedId}
degraded={pollDegraded}
/>
)}
<p className="mt-1 text-xs text-muted-foreground">{summary}</p>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border pt-3">
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!canRetry || retrying}
className="gap-2"
>
{retrying ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Retry
</Button>
<ul
className="max-h-80 space-y-2 overflow-y-auto pr-1"
data-testid="po-upload-rows"
>
{rows.map((r) => (
<li key={r.uid}>
<RowPanel
row={r}
degraded={isDegraded(r)}
canPollRetry={canPollRetry(r)}
onUploadRetry={() => onUploadRetry(r.uid)}
onPollRetry={() => onPollRetry(r.uid)}
/>
</li>
))}
</ul>
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
<Button onClick={onClose}>Done</Button>
</div>
</div>
);
}
interface RowPanelProps {
row: FileRow;
degraded: boolean;
canPollRetry: boolean;
onUploadRetry: () => void;
onPollRetry: () => void;
}
function RowPanel({
row,
degraded,
canPollRetry,
onUploadRetry,
onPollRetry,
}: RowPanelProps) {
return (
<div className="rounded-md border border-border bg-card/40 p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs text-foreground/90">
{row.file.name}
</p>
<p className="text-[11px] text-muted-foreground">
{(row.file.size / 1024).toFixed(1)} KB - {row.file.type || "unknown"}
</p>
</div>
<UploadRowPill row={row} degraded={degraded} />
</div>
{(row.uploadKind === "error" || canPollRetry) && (
<div className="mt-2 flex items-center justify-end gap-2">
{row.uploadKind === "error" && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onUploadRetry}
disabled={row.retrying}
className="gap-1"
>
{row.retrying ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Retry upload
</Button>
)}
{canPollRetry && (
<Button
type="button"
size="sm"
variant="outline"
onClick={onPollRetry}
disabled={row.retrying}
className="gap-1"
>
{row.retrying ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Retry
</Button>
)}
</div>
)}
{row.uploadKind === "error" && row.uploadError && (
<p className="mt-1 truncate text-[11px] text-destructive/80">
{row.uploadError}
</p>
)}
</div>
);
}
function UploadRowPill({
row,
degraded,
}: {
row: FileRow;
degraded: boolean;
}) {
if (row.uploadKind === "uploading") {
return (
<span
role="status"
aria-live="polite"
className={`${PILL_BASE} ${PILL_TONE.blue}`}
data-testid="batch-status"
>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Uploading
</span>
);
}
if (row.uploadKind === "duplicate") {
return (
<span
role="status"
aria-live="polite"
className={`${PILL_BASE} ${PILL_TONE.yellow}`}
data-testid="batch-status"
title={
row.duplicatePoId
? `Already imported as PO ${row.duplicatePoId}`
: "Already imported recently"
}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Duplicate
</span>
);
}
if (row.uploadKind === "error") {
return (
<span
role="status"
aria-live="polite"
title={row.uploadError ?? "Upload failed"}
className={`${PILL_BASE} ${PILL_TONE.red}`}
data-testid="batch-status"
>
<AlertCircle className="mr-1 h-3 w-3" />
Failed
</span>
);
}
// queued - show poll-derived pill
return (
<BatchStatusPill
status={row.status}
error={row.batchError}
processedId={row.processedId}
degraded={degraded}
/>
);
}
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 (
<p
role="status"
aria-live="polite"
className="text-xs"
data-testid="batch-status"
>
<span className={`${PILL_BASE} ${PILL_TONE.yellow}`}>
<Clock className="mr-1 h-3 w-3" />
{degraded ? "Can't reach server - retrying…" : "Queued"}
</span>
</p>
);
}
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({
<p
role="status"
aria-live="polite"
className="mt-3 text-xs"
className="text-xs"
data-testid="batch-status"
>
<span className={`${PILL_BASE} ${PILL_TONE[tone]}`}>
{icon}
{degraded ? "Can't reach server — retrying…" : label}
{degraded ? "Can't reach server - retrying…" : label}
</span>
{!degraded && sub && <span className="ml-2 text-muted-foreground">{sub}</span>}
{!degraded && sub && (
<span className="ml-2 text-muted-foreground">{sub}</span>
)}
</p>
);
}

View file

@ -75,6 +75,21 @@ export async function pbAdmin(): Promise<PocketBase> {
}
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 {