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:
parent
7fd46d9ebf
commit
18ebf6d217
4 changed files with 576 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue