feat(delivery-notes): add the batch detail view with note correction

This commit is contained in:
DEls 2026-09-17 14:45:47 +02:00
parent ddc17b29c1
commit 85c1a456d7
2 changed files with 400 additions and 0 deletions

View file

@ -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>
);
}

View file

@ -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>
</>
);
}