feat(delivery-notes): add the inbox kanban board
This commit is contained in:
parent
575352c553
commit
ddc17b29c1
3 changed files with 183 additions and 1 deletions
106
src/app/dashboard/financial/delivery-notes/inbox/inbox-board.tsx
Normal file
106
src/app/dashboard/financial/delivery-notes/inbox/inbox-board.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/dashboard/financial/delivery-notes/inbox/page.tsx
Normal file
70
src/app/dashboard/financial/delivery-notes/inbox/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue