diff --git a/src/app/dashboard/financial/delivery-notes/inbox/[id]/batch-detail-view.tsx b/src/app/dashboard/financial/delivery-notes/inbox/[id]/batch-detail-view.tsx new file mode 100644 index 0000000..91d76f4 --- /dev/null +++ b/src/app/dashboard/financial/delivery-notes/inbox/[id]/batch-detail-view.tsx @@ -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(); + 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 ( +
+
+ {status} + + +
+ + {batchChecks.filter((c) => c.passed === false).length > 0 && ( + + + Batch problems + + + {batchChecks + .filter((c) => c.passed === false) + .map((c) => ( +

+ {c.id}: {c.reason} +

+ ))} +
+
+ )} + + {orphanPages.length > 0 && ( + + + + Pages belonging to no note: {orphanPages.join(", ")} + + + + {orphanPages.map((p) => ( + + ))} + + + )} + + {notes.map((n) => ( + + + + {n.deliveryNoteNumber || "(no note number)"} + + page{n.pageFrom === n.pageTo ? "" : "s"} {n.pageFrom} + {n.pageFrom === n.pageTo ? "" : `–${n.pageTo}`} + + + {n.status} + + + + +
+ {Array.from({ length: n.pageTo - n.pageFrom + 1 }, (_, i) => n.pageFrom + i).map( + (p) => ( + + ), + )} +
+
+
+ + + + + + +
+ +
    + {n.items.map((it, i) => ( +
  • + {it.description} + + {formatNumber(it.qty)} {it.unit ?? ""} + +
  • + ))} +
+ +
    + {n.checks.map((c) => ( +
  • + {c.passed === false ? "✗" : c.passed === null ? "–" : "✓"} {c.id}: {c.reason} +
  • + ))} +
+ + +
+
+
+ ))} +
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( + <> +
{label}
+
{value}
+ + ); +} + +function PageImage({ batchId, page }: { batchId: string; page: number }) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Page + p{page} + + ); +} + +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 = { + 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 ( +

+ Promoted — delete the delivery note before editing this candidate. +

+ ); + } + + if (!open) { + return ( + + ); + } + + 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 ( +
+
+ {EDITABLE_FIELDS.map((f) => ( + + ))} +
+ {error &&

{error}

} +
+ + +
+

+ Saving only updates the candidate. Re-run the checks to promote it. +

+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/financial/delivery-notes/inbox/[id]/page.tsx b/src/app/dashboard/financial/delivery-notes/inbox/[id]/page.tsx new file mode 100644 index 0000000..0297469 --- /dev/null +++ b/src/app/dashboard/financial/delivery-notes/inbox/[id]/page.tsx @@ -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 ( + <> + +
+ +
+ + ); +} \ No newline at end of file