feat(delivery-notes): add the batch detail view with note correction
This commit is contained in:
parent
ddc17b29c1
commit
85c1a456d7
2 changed files with 400 additions and 0 deletions
|
|
@ -0,0 +1,330 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
import type { CheckResult, PageItem } from "@/lib/dn-pipeline/types";
|
||||
|
||||
export interface BatchNoteView {
|
||||
id: string;
|
||||
noteIndex: number;
|
||||
pageFrom: number;
|
||||
pageTo: number;
|
||||
deliveryNoteNumber: string;
|
||||
poNumberRaw: string;
|
||||
poNumber: string;
|
||||
grossMass: number | null;
|
||||
tareMass: number | null;
|
||||
nettMass: number | null;
|
||||
quantity: number | null;
|
||||
unit: string;
|
||||
status: string;
|
||||
items: PageItem[];
|
||||
checks: CheckResult[];
|
||||
deliveryNoteId: string;
|
||||
}
|
||||
|
||||
export function BatchDetailView({
|
||||
batchId,
|
||||
status,
|
||||
pageCount,
|
||||
batchChecks,
|
||||
notes,
|
||||
}: {
|
||||
batchId: string;
|
||||
status: string;
|
||||
pageCount: number;
|
||||
batchChecks: CheckResult[];
|
||||
notes: BatchNoteView[];
|
||||
}) {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
async function rerun() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fetch(`/api/dn-batches/${batchId}/rerun`, { method: "POST" });
|
||||
window.location.reload();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismiss() {
|
||||
if (!confirm("Mark this batch as already ingested? No delivery notes will be created from it.")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fetch(`/api/dn-batches/${batchId}/dismiss`, { method: "POST" });
|
||||
window.location.reload();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Pages belonging to no note — the failure that used to lose notes
|
||||
// silently. Show them first; they are the thing to act on.
|
||||
const claimed = new Set<number>();
|
||||
for (const n of notes) {
|
||||
for (let p = n.pageFrom; p <= n.pageTo; p++) claimed.add(p);
|
||||
}
|
||||
const orphanPages = Array.from({ length: pageCount }, (_, i) => i + 1).filter(
|
||||
(p) => !claimed.has(p),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{status}</Badge>
|
||||
<Button size="sm" variant="outline" onClick={rerun} disabled={busy}>
|
||||
Re-run checks
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={dismiss} disabled={busy}>
|
||||
Already ingested
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{batchChecks.filter((c) => c.passed === false).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Batch problems</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
{batchChecks
|
||||
.filter((c) => c.passed === false)
|
||||
.map((c) => (
|
||||
<p key={c.id} className="text-destructive">
|
||||
{c.id}: {c.reason}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{orphanPages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Pages belonging to no note: {orphanPages.join(", ")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{orphanPages.map((p) => (
|
||||
<PageImage key={p} batchId={batchId} page={p} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{notes.map((n) => (
|
||||
<Card key={n.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{n.deliveryNoteNumber || "(no note number)"}</span>
|
||||
<Badge variant="outline">
|
||||
page{n.pageFrom === n.pageTo ? "" : "s"} {n.pageFrom}
|
||||
{n.pageFrom === n.pageTo ? "" : `–${n.pageTo}`}
|
||||
</Badge>
|
||||
<Badge variant={n.status === "promoted" ? "outline" : "destructive"}>
|
||||
{n.status}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-[auto_1fr]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: n.pageTo - n.pageFrom + 1 }, (_, i) => n.pageFrom + i).map(
|
||||
(p) => (
|
||||
<PageImage key={p} batchId={batchId} page={p} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3 text-sm">
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<Field label="Order no" value={n.poNumberRaw} />
|
||||
<Field label="Matched PO" value={n.poNumber || "—"} />
|
||||
<Field label="Gross" value={n.grossMass === null ? "—" : formatNumber(n.grossMass)} />
|
||||
<Field label="Tare" value={n.tareMass === null ? "—" : formatNumber(n.tareMass)} />
|
||||
<Field label="Nett" value={n.nettMass === null ? "—" : formatNumber(n.nettMass)} />
|
||||
<Field
|
||||
label="Quantity"
|
||||
value={n.quantity === null ? "—" : `${formatNumber(n.quantity)} ${n.unit}`}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{n.items.map((it, i) => (
|
||||
<li key={i} className="flex justify-between gap-2 rounded-md bg-muted/30 px-2 py-1">
|
||||
<span className="min-w-0 truncate">{it.description}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(it.qty)} {it.unit ?? ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{n.checks.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className={
|
||||
c.passed === false
|
||||
? "text-destructive"
|
||||
: c.passed === null
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{c.passed === false ? "✗" : c.passed === null ? "–" : "✓"} {c.id}: {c.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<NoteEditor batchId={batchId} note={n} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-mono tabular-nums">{value}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PageImage({ batchId, page }: { batchId: string; page: number }) {
|
||||
return (
|
||||
<a
|
||||
href={`/api/dn-batches/${batchId}/pages/${page}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/dn-batches/${batchId}/pages/${page}`}
|
||||
alt={`Page ${page}`}
|
||||
className="h-40 w-auto rounded-md border object-contain"
|
||||
/>
|
||||
<span className="mt-1 block text-center text-xs text-muted-foreground">p{page}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const EDITABLE_FIELDS: { key: string; label: string; numeric: boolean }[] = [
|
||||
{ key: "delivery_note_number", label: "Note number", numeric: false },
|
||||
{ key: "po_number_raw", label: "Order no", numeric: false },
|
||||
{ key: "quantity", label: "Quantity", numeric: true },
|
||||
{ key: "unit", label: "Unit", numeric: false },
|
||||
{ key: "gross_mass", label: "Gross", numeric: true },
|
||||
{ key: "tare_mass", label: "Tare", numeric: true },
|
||||
{ key: "nett_mass", label: "Nett", numeric: true },
|
||||
{ key: "page_from", label: "First page", numeric: true },
|
||||
{ key: "page_to", label: "Last page", numeric: true },
|
||||
];
|
||||
|
||||
function NoteEditor({
|
||||
batchId,
|
||||
note,
|
||||
}: {
|
||||
batchId: string;
|
||||
note: BatchNoteView;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
const initial: Record<string, string> = {
|
||||
delivery_note_number: note.deliveryNoteNumber,
|
||||
po_number_raw: note.poNumberRaw,
|
||||
quantity: note.quantity === null ? "" : String(note.quantity),
|
||||
unit: note.unit,
|
||||
gross_mass: note.grossMass === null ? "" : String(note.grossMass),
|
||||
tare_mass: note.tareMass === null ? "" : String(note.tareMass),
|
||||
nett_mass: note.nettMass === null ? "" : String(note.nettMass),
|
||||
page_from: String(note.pageFrom),
|
||||
page_to: String(note.pageTo),
|
||||
};
|
||||
const [values, setValues] = React.useState(initial);
|
||||
|
||||
if (note.status === "promoted") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Promoted — delete the delivery note before editing this candidate.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
Correct this note
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setError("");
|
||||
const from = Number(values.page_from);
|
||||
const to = Number(values.page_to);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || from > to) {
|
||||
setError("Page range must be whole numbers with the first page no later than the last.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/dn-batches/${batchId}/notes/${note.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.detail || body.error || `Save failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{EDITABLE_FIELDS.map((f) => (
|
||||
<label key={f.key} className="text-xs">
|
||||
<span className="text-muted-foreground">{f.label}</span>
|
||||
<Input
|
||||
className="mt-0.5 h-8"
|
||||
inputMode={f.numeric ? "decimal" : "text"}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(e) => {
|
||||
setValues((v) => ({ ...v, [f.key]: e.target.value }));
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saving only updates the candidate. Re-run the checks to promote it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { pbAdmin } from "@/lib/pb";
|
||||
import { Topbar } from "@/components/layout/sidebar";
|
||||
import { BatchDetailView, type BatchNoteView } from "./batch-detail-view";
|
||||
import type { CheckResult, PageItem } from "@/lib/dn-pipeline/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BatchDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const pb = await pbAdmin();
|
||||
|
||||
let batch;
|
||||
try {
|
||||
batch = await pb.collection("dn_batches").getOne(id, { expand: "site" });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notes = await pb.collection("dn_batch_notes").getFullList({
|
||||
filter: `batch='${id}'`,
|
||||
sort: "note_index",
|
||||
expand: "purchase_order",
|
||||
});
|
||||
|
||||
const noteViews: BatchNoteView[] = notes.map((n) => ({
|
||||
id: n.id,
|
||||
noteIndex: n.note_index ?? 0,
|
||||
pageFrom: n.page_from ?? 0,
|
||||
pageTo: n.page_to ?? 0,
|
||||
deliveryNoteNumber: n.delivery_note_number ?? "",
|
||||
poNumberRaw: n.po_number_raw ?? "",
|
||||
poNumber:
|
||||
(n.expand?.purchase_order as { purchase_order_number?: string } | undefined)
|
||||
?.purchase_order_number ?? "",
|
||||
grossMass: n.gross_mass ?? null,
|
||||
tareMass: n.tare_mass ?? null,
|
||||
nettMass: n.nett_mass ?? null,
|
||||
quantity: n.quantity ?? null,
|
||||
unit: n.unit ?? "",
|
||||
status: n.status ?? "candidate",
|
||||
items: (Array.isArray(n.items) ? n.items : []) as PageItem[],
|
||||
checks: (Array.isArray(n.checks) ? n.checks : []) as CheckResult[],
|
||||
deliveryNoteId: n.delivery_note ?? "",
|
||||
}));
|
||||
|
||||
const site = batch.expand?.site as { name?: string } | undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
title={batch.file || "Batch"}
|
||||
subtitle={`${site?.name ?? "(unknown site)"} · ${batch.page_count ?? 0} pages · ${batch.notes_promoted ?? 0} of ${batch.notes_detected ?? 0} notes`}
|
||||
/>
|
||||
<div className="p-4 sm:p-6">
|
||||
<BatchDetailView
|
||||
batchId={batch.id}
|
||||
status={batch.status ?? "inbox"}
|
||||
pageCount={batch.page_count ?? 0}
|
||||
batchChecks={(Array.isArray(batch.checks) ? batch.checks : []) as CheckResult[]}
|
||||
notes={noteViews}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue