feat(delivery-notes): add the inbox kanban board

This commit is contained in:
DEls 2026-09-17 14:43:18 +02:00
parent 575352c553
commit ddc17b29c1
3 changed files with 183 additions and 1 deletions

View file

@ -0,0 +1,106 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { cn, formatNumber } from "@/lib/utils";
export interface BatchCard {
id: string;
filename: string;
siteName: string;
status: string;
pageCount: number;
notesDetected: number;
notesPromoted: number;
totalQty: number;
totalUnit: string;
failedChecks: string[];
lastError: string;
}
const COLUMNS: { key: string; label: string }[] = [
{ key: "inbox", label: "Inbox" },
{ key: "analysing", label: "Analysing" },
{ key: "needs_attention", label: "Needs attention" },
{ key: "processed", label: "Processed" },
];
/** Processed batches are historical — cap the column and link to the full list. */
const PROCESSED_LIMIT = 20;
export function InboxBoard({ cards }: { cards: BatchCard[] }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => {
const all = cards.filter((c) => c.status === col.key);
const shown = col.key === "processed" ? all.slice(0, PROCESSED_LIMIT) : all;
return (
<div key={col.key} className="min-w-0">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm text-muted-foreground">{col.label}</span>
<span className="text-xs text-muted-foreground">{all.length}</span>
</div>
<div className="space-y-2">
{shown.map((c) => (
<Card key={c.id} card={c} />
))}
{all.length === 0 && (
<p className="rounded-xl border border-dashed p-4 text-center text-xs text-muted-foreground">
Nothing here
</p>
)}
{all.length > shown.length && (
<p className="px-1 text-xs text-muted-foreground">
+ {all.length - shown.length} older
</p>
)}
</div>
</div>
);
})}
</div>
);
}
function Card({ card }: { card: BatchCard }) {
const complete =
card.notesDetected > 0 && card.notesPromoted === card.notesDetected;
return (
<Link
href={`/dashboard/financial/delivery-notes/inbox/${card.id}`}
className="block rounded-xl border bg-card p-3 transition-colors hover:border-foreground/30"
>
<p className="break-all font-mono text-xs">{card.filename}</p>
<p className="mt-1.5 text-xs text-muted-foreground">
{card.siteName}
{card.pageCount > 0 && ` · ${card.pageCount} page${card.pageCount === 1 ? "" : "s"}`}
</p>
{card.notesDetected > 0 && (
<p
className={cn(
"mt-1 text-xs",
complete ? "text-muted-foreground" : "text-amber-700 dark:text-amber-300",
)}
>
{card.notesPromoted} of {card.notesDetected} notes
</p>
)}
{card.totalQty > 0 && (
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
{formatNumber(card.totalQty)} {card.totalUnit}
</p>
)}
<div className="mt-2 flex flex-wrap gap-1">
{card.status === "processed" && complete && (
<Badge variant="outline">All checks passed</Badge>
)}
{card.failedChecks.map((reason) => (
<Badge key={reason} variant="destructive" className="whitespace-normal text-left">
{reason}
</Badge>
))}
{card.lastError && <Badge variant="destructive">{card.lastError.slice(0, 60)}</Badge>}
</div>
</Link>
);
}

View file

@ -0,0 +1,70 @@
import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope";
import { getSiteScope } from "@/lib/site-scope-server";
import { InboxBoard, type BatchCard } from "./inbox-board";
import type { CheckResult } from "@/lib/dn-pipeline/types";
export const dynamic = "force-dynamic";
export default async function InboxPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const scope = await getSiteScope(await searchParams);
const pb = await pbAdmin();
const batches = await pb.collection("dn_batches").getFullList({
expand: "site",
sort: "-created",
...filterOpt(siteFilter(scope)),
});
const noteRows = await pb.collection("dn_batch_notes").getFullList({
fields: "batch,quantity,unit,status",
});
const totals = new Map<string, { qty: number; unit: string }>();
for (const n of noteRows) {
const row = n as unknown as { batch: string; quantity?: number; unit?: string };
const cur = totals.get(row.batch) ?? { qty: 0, unit: row.unit ?? "" };
cur.qty += row.quantity ?? 0;
if (!cur.unit && row.unit) cur.unit = row.unit;
totals.set(row.batch, cur);
}
const cards: BatchCard[] = batches.map((b) => {
const site = b.expand?.site as { name?: string } | undefined;
const total = totals.get(b.id);
const checks = Array.isArray(b.checks) ? (b.checks as CheckResult[]) : [];
return {
id: b.id,
filename: b.file ?? "(no file)",
siteName: site?.name ?? "(unknown site)",
status: b.status ?? "inbox",
pageCount: b.page_count ?? 0,
notesDetected: b.notes_detected ?? 0,
notesPromoted: b.notes_promoted ?? 0,
totalQty: total?.qty ?? 0,
totalUnit: total?.unit ?? "",
failedChecks: checks
.filter((c) => c.passed === false)
.map((c) => c.reason)
.slice(0, 3),
lastError: b.last_error ?? "",
};
});
return (
<>
<Topbar
title="Delivery note inbox"
subtitle="Every uploaded document, and exactly where it is in the process."
/>
<div className="p-4 sm:p-6">
<InboxBoard cards={cards} />
</div>
</>
);
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { ClipboardList } from "lucide-react";
import { ClipboardList, Inbox } from "lucide-react";
import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { Button } from "@/components/ui/button";
@ -118,6 +118,12 @@ export default async function DeliveryNotesPage({
Upload status
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="gap-1.5">
<Link href="/dashboard/financial/delivery-notes/inbox">
<Inbox className="h-3.5 w-3.5" />
Inbox
</Link>
</Button>
<UploadDeliveryNoteButton
sites={scope.allowed}
/>