feat(dn): inbox 3 slip-first matching screen

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 19:36:18 +02:00
parent 1514d514b3
commit 402824e018
14 changed files with 1066 additions and 10 deletions

View file

@ -876,13 +876,16 @@ export function PageViewer({
orderPages,
purchaseOrderNumber,
fullWidth = false,
side = "note",
}: {
fullWidth?: boolean;
notePages: ViewerPage[];
orderPages: ViewerPage[];
purchaseOrderNumber: string;
/** Which side to open on: a screen that shows only the order asks for it. */
side?: "note" | "order";
}) {
const [showing, setShowing] = React.useState<"note" | "order">("note");
const [showing, setShowing] = React.useState<"note" | "order">(side);
const [index, setIndex] = React.useState(0);
const [zoom, setZoom] = React.useState(1);
const [rotation, setRotation] = React.useState<0 | 90 | 180 | 270>(() => {
@ -1004,7 +1007,7 @@ export function PageViewer({
>
{/* Only offered when there is an order to compare against: an
unmatched note has nothing on the other side of the toggle. */}
{orderPages.length > 0 && (
{orderPages.length > 0 && notePages.length > 0 && (
<div className="flex border-b bg-muted/30 p-1 text-sm">
{([
["note", "Delivery note"],

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ArrowRight, Check, CheckCheck, CircleHelp, Clock3, FileText, Inbox, Search, Archive, RefreshCw } from 'lucide-react';
import { cn, formatNumber } from '@/lib/utils';
import { filterInboxRows, type InboxCategory, type InboxRow } from '@/lib/inbox-two/model';
import { filterInboxRows, rowInCategory, type InboxCategory, type InboxRow } from '@/lib/inbox-two/model';
const tabs: { id: InboxCategory; label: string; icon: typeof Inbox }[] = [
{ id: 'attention', label: 'Needs attention', icon: CircleHelp }, { id: 'ready', label: 'Ready to confirm', icon: CheckCheck },
@ -22,12 +22,12 @@ function age(since: string, now: string) {
const days = Math.floor((Date.parse(now) - Date.parse(since)) / 86400000);
return !Number.isFinite(days) ? '' : days <= 0 ? 'Today' : days === 1 ? '1 day ago' : `${days} days ago`;
}
export function InboxTwoView({ rows, now }: { rows: InboxRow[]; now: string }) {
export function InboxTwoView({ rows, now, inbox = 'inbox-2' }: { rows: InboxRow[]; now: string; inbox?: 'inbox-2' | 'inbox-3' }) {
const router = useRouter();
const [category, setCategory] = useState<InboxCategory>(rows.some(r => r.category === 'attention') ? 'attention' : rows.some(r => r.category === 'ready') ? 'ready' : 'processing');
const [search, setSearch] = useState('');
useEffect(() => { const timer = setInterval(() => { if (document.visibilityState === 'visible') router.refresh(); }, 20000); return () => clearInterval(timer); }, [router]);
const visible = filterInboxRows(rows, category, search), counts = Object.fromEntries(tabs.map(t => [t.id, rows.filter(r => r.category === t.id).length]));
const visible = filterInboxRows(rows, category, search), counts = Object.fromEntries(tabs.map(t => [t.id, rows.filter(r => rowInCategory(r, t.id)).length]));
const open = counts.attention + counts.ready + counts.processing;
return <div className="mx-auto max-w-[1480px] space-y-7 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-end justify-between gap-4">
@ -61,7 +61,7 @@ export function InboxTwoView({ rows, now }: { rows: InboxRow[]; now: string }) {
</div>
</Link>)}
</div> : <div className="flex min-h-56 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><Inbox className="mb-4 h-8 w-8 text-muted-foreground" /><p className="font-medium">{search ? 'No matching deliveries in this view' : category === 'attention' ? 'Nothing needs your attention' : `No deliveries ${category === 'ready' ? 'waiting for confirmation' : category === 'processing' ? 'being checked' : 'in this view'}`}</p><p className="mt-2 max-w-md text-sm text-muted-foreground">{search ? 'Try another reference or choose a different status above.' : 'New scans and updates will appear here automatically.'}</p>{search && <button className="mt-3 min-h-11 px-4 text-sm underline" onClick={() => setSearch('')}>Clear search</button>}</div>}
<p className="text-xs leading-relaxed text-muted-foreground">Inbox 2 uses the same delivery records as Inbox 1. Confirmed deliveries appear in both views.</p>
<p className="text-xs leading-relaxed text-muted-foreground">{inbox === 'inbox-3' ? 'Inbox 3 shows the slip first: the lines that still need sorting, one by one.' : 'Inbox 2 uses the same delivery records as Inbox 1. Confirmed deliveries appear in both views.'}</p>
</div>
</div>;
}

View file

@ -0,0 +1,94 @@
import { notFound } from "next/navigation";
import { Topbar } from "@/components/layout/sidebar";
import { pbAdmin } from "@/lib/pb";
import { requirePageUser } from "@/lib/auth/guard";
import { canSeeInboxThree } from "@/lib/auth/access";
import { loadPreviewContext } from "@/lib/dn-pipeline/ai-preview-context";
import { buildLineRows } from "@/lib/inbox-three/line-rows";
import { decisionHash } from "@/lib/dn-pipeline/decision-hash";
import { signDnPageProxyToken, signPoDocumentToken } from "@/lib/dn-page-token";
import { pdfPageCount } from "@/lib/pdf-page-count";
import { fetchPurchaseOrderDocument } from "@/lib/purchase-order-document";
import { finiteNumber } from "@/lib/dn-pipeline/ai-preview";
import { InboxThreeWorkspace } from "../inbox-three-workspace";
export const dynamic = "force-dynamic";
/**
* One slip, the way the office reads it: the lines that still need sorting.
*
* The saved match is the screen's starting point. A slip that has never
* been matched has no lines to sort, so it says so instead of showing an
* empty table.
*/
export default async function InboxThreeDocumentPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await requirePageUser("financial");
if (!canSeeInboxThree(user)) notFound();
const pb = await pbAdmin();
const note = await pb.collection("dn_batch_notes").getOne(id, {
expand: "supplier,purchase_order,batch,batch.site",
fields: "id,batch,supplier,purchase_order,doc_number,delivery_note_number,po_number_raw,date,pages,page_from,page_to,status,stage,reconciliation_preview,expand",
}).catch(() => null);
if (!note) notFound();
const batch = note.expand?.batch as Record<string, unknown> | undefined;
if (!batch) notFound();
const batchId = String(batch.id);
const site = (batch.expand as { site?: { name?: string } } | undefined)?.site;
const loaded = await loadPreviewContext(pb, batchId, id).catch(() => null);
if (!loaded?.stored) notFound();
const rows = buildLineRows(loaded.context, loaded.stored);
const stored = loaded.stored;
const pages: number[] = Array.isArray(note.pages) && note.pages.length
? (note.pages as number[])
: Array.from({ length: Math.max(0, Number(note.page_to ?? 0) - Number(note.page_from ?? 0) + 1) }, (_, index) => Number(note.page_from ?? 0) + index).filter((page) => page > 0);
const slipPages = pages.map((page) => ({ label: `p${page}`, src: `/api/dn-batches/${batchId}/pages/${page}?token=${encodeURIComponent(signDnPageProxyToken(batchId, page))}` }));
const purchaseOrderId = typeof note.purchase_order === "string" ? note.purchase_order : "";
const purchaseOrder = note.expand?.purchase_order as { purchase_order_file?: string; purchase_order_number?: string } | undefined;
const poNumber = purchaseOrder?.purchase_order_number ?? String(note.po_number_raw ?? "");
const purchaseOrderToken = purchaseOrderId && purchaseOrder?.purchase_order_file ? signPoDocumentToken(purchaseOrderId) : "";
const poPages = purchaseOrderToken
? Array.from({ length: await purchaseOrderPages(pb, purchaseOrderId) }, (_, index) => ({
label: `p${index + 1}`,
src: `/api/purchase-orders/${purchaseOrderId}/pages/${index + 1}?token=${encodeURIComponent(purchaseOrderToken)}`,
}))
: [];
return <>
<Topbar title={String(note.doc_number || note.delivery_note_number || "Delivery slip")} subtitle={`${(note.expand?.supplier as { name?: string } | undefined)?.name ?? note.po_number_raw ?? "Delivery slip"} · ${site?.name ?? ""}`} />
<InboxThreeWorkspace
batchId={batchId}
noteId={id}
docNumber={String(note.doc_number || note.delivery_note_number || "")}
supplierName={(note.expand?.supplier as { name?: string } | undefined)?.name ?? ""}
date={note.date ? String(note.date) : null}
poNumber={poNumber}
siteName={site?.name ?? ""}
rows={rows}
poItems={loaded.context.poItems.map((item) => ({
id: String(item.id),
description: typeof item.description === "string" ? item.description : "",
unit: typeof item.unit === "string" ? item.unit : "",
qty: finiteNumber(item.qty),
rate: finiteNumber(item.rate),
}))}
snapshotHash={stored.snapshotHash}
decisionHash={decisionHash(stored)}
slipPages={slipPages}
poPages={poPages}
slipValue={rows.reduce((sum, row) => sum + (row.value ?? 0), 0)}
poValue={finiteNumber(loaded.context.po.total_value)}
/>
</>;
}
/** Page count of the order's stored file, or 0 when there is none. */
async function purchaseOrderPages(pb: Awaited<ReturnType<typeof pbAdmin>>, purchaseOrderId: string): Promise<number> {
const document = await fetchPurchaseOrderDocument(pb, purchaseOrderId);
if ("error" in document) return 0;
return pdfPageCount(document.bytes);
}

View file

@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { PageViewer } from "../document/[id]/document-detail-view";
/**
* The slip and the order, side by side.
*
* The slip is what arrived; the order is what it is supposed to be. Both
* are on screen so a line can be read against the order without leaving
* the page.
*/
export function DocumentsPanel({
slipPages,
poPages,
poNumber,
}: {
slipPages: { label: string; src: string }[];
poPages: { label: string; src: string }[];
poNumber: string;
}) {
const [tab, setTab] = useState<"both" | "slip" | "po">("both");
const tabs: { id: typeof tab; label: string }[] = [
{ id: "both", label: "Both" },
{ id: "slip", label: "Slip" },
{ id: "po", label: "PO" },
];
return (
<section className="space-y-2" aria-label="The slip and the order">
<div className="flex gap-2" role="tablist" aria-label="Which document to show">
{tabs.map(({ id, label }) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => setTab(id)}
className={`min-h-11 rounded-full border px-3 text-sm ${tab === id ? "border-foreground bg-foreground text-background" : "text-muted-foreground"}`}
>
{label}
</button>
))}
</div>
{tab !== "po" && slipPages.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold">Delivery slip</p>
<PageViewer fullWidth notePages={slipPages} orderPages={[]} purchaseOrderNumber="" />
</div>
)}
{tab !== "slip" && poPages.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold">Purchase order {poNumber}</p>
<PageViewer fullWidth notePages={[]} orderPages={poPages} purchaseOrderNumber={poNumber} side="order" />
</div>
)}
{tab !== "po" && !slipPages.length && <p className="text-xs text-muted-foreground">No slip photo is available.</p>}
{tab !== "slip" && !poPages.length && <p className="text-xs text-muted-foreground">This order has no document on file.</p>}
</section>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { formatNumber } from "@/lib/utils";
import type { LineRow } from "@/lib/inbox-three/line-rows";
import { DocumentsPanel } from "./documents-panel";
import { LineAssistant } from "./line-assistant";
import { LineTable } from "./line-table";
import { ReadyBanner } from "./ready-banner";
import type { PoLineChoice } from "./po-line-picker";
export interface InboxThreeWorkspaceProps {
batchId: string;
noteId: string;
docNumber: string;
supplierName: string;
date: string | null;
poNumber: string;
siteName: string;
rows: LineRow[];
poItems: PoLineChoice[];
snapshotHash: string;
decisionHash: string;
slipPages: { label: string; src: string }[];
poPages: { label: string; src: string }[];
/** What the whole slip is worth, and what the order is worth. */
slipValue: number | null;
poValue: number | null;
}
/**
* One slip, the way the office reads it.
*
* The slip comes first: the lines that still need sorting are the screen,
* the order and the assistant sit beside it. A line that is ready leaves
* the table and is kept in the banner, so what is left on screen is always
* the work.
*/
export function InboxThreeWorkspace(props: InboxThreeWorkspaceProps) {
const router = useRouter();
const [rows, setRows] = useState<LineRow[]>(props.rows);
const [readyNow, setReadyNow] = useState<number[]>([]);
const ready = rows.filter((row) => row.ready);
const open = rows.filter((row) => !row.ready && !row.posted);
const openValue = open.reduce((sum, row) => sum + (row.value ?? 0), 0);
function apply(result: { rows: LineRow[]; readyNow: number[] }) {
setRows(result.rows);
setReadyNow(result.readyNow);
}
return (
<div className="mx-auto max-w-[1600px] space-y-5 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<Link href="/dashboard/financial/delivery-notes/inbox-3" className="inline-flex min-h-11 items-center gap-2 font-medium hover:underline">
<ArrowLeft className="h-4 w-4" />Back to Inbox 3
</Link>
</div>
<header className="flex flex-wrap items-end justify-between gap-4 border-b pb-4">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">{props.siteName} · Delivery slip</p>
<h1 className="text-2xl font-semibold tracking-tight">{props.docNumber || "Reference to confirm"}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{props.supplierName || "Supplier to confirm"}{props.date && ` · ${props.date}`}{props.poNumber && ` · PO ${props.poNumber}`}
</p>
</div>
</header>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.8fr)]">
<div className="order-2 min-w-0 space-y-4 xl:order-1">
<ReadyBanner
lines={ready}
batchId={props.batchId}
noteId={props.noteId}
snapshotHash={props.snapshotHash}
decisionHash={props.decisionHash}
onPosted={() => router.refresh()}
/>
<LineTable
rows={rows}
poItems={props.poItems}
batchId={props.batchId}
noteId={props.noteId}
snapshotHash={props.snapshotHash}
decisionHash={props.decisionHash}
onSaved={apply}
/>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Still here: <strong>{open.length} lines</strong> · R{formatNumber(openValue, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<p className="text-muted-foreground">
Whole slip {props.slipValue === null ? "—" : `R${formatNumber(props.slipValue, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
{" vs PO "}
{props.poValue === null ? "—" : `R${formatNumber(props.poValue, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
</p>
</div>
{readyNow.length > 0 && <p className="text-xs text-muted-foreground">{readyNow.length} lines can be confirmed now.</p>}
</div>
<div className="order-1 min-w-0 space-y-5 xl:order-2">
<DocumentsPanel slipPages={props.slipPages} poPages={props.poPages} poNumber={props.poNumber} />
<LineAssistant batchId={props.batchId} noteId={props.noteId} onChanged={apply} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { LineRow } from "@/lib/inbox-three/line-rows";
interface ChatTurn { role: "you" | "bot"; text: string }
/**
* Talking to the assistant about this slip.
*
* It can change the lines — which order line a slip line belongs to, how
* many, in what unit — but it never posts. What it changes is checked the
* same way a hand-made change is, and the office still confirms.
*/
export function LineAssistant({
batchId,
noteId,
onChanged,
}: {
batchId: string;
noteId: string;
onChanged: (result: { rows: LineRow[]; readyNow: number[] }) => void;
}) {
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function send() {
const text = message.trim();
if (!text || busy) return;
setBusy(true);
setError("");
setTurns((current) => [...current, { role: "you", text }]);
setMessage("");
try {
const response = await fetch(`/api/inbox-three/${batchId}/notes/${noteId}/assistant`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId: crypto.randomUUID(), message: text }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw Error(body?.error || body?.detail || "The assistant could not answer that.");
setTurns((current) => [...current, { role: "bot", text: body.reply ?? "" }]);
if (Array.isArray(body.rows)) onChanged({ rows: body.rows, readyNow: body.readyNow ?? [] });
} catch (failure) {
setError(failure instanceof Error ? failure.message : "The assistant could not answer that.");
} finally {
setBusy(false);
}
}
return (
<section className="space-y-2" aria-label="The delivery assistant">
<div className="flex flex-col gap-2 rounded-lg border p-3">
{turns.map((turn, index) => (
<p key={index} className={`max-w-[92%] rounded-lg px-3 py-2 text-sm ${turn.role === "you" ? "self-end bg-primary/10" : "self-start bg-muted"}`}>
{turn.text}
</p>
))}
{!turns.length && <p className="text-xs text-muted-foreground">Tell the assistant, e.g. &ldquo;line 3 is the bailing wire&rdquo;.</p>}
{error && <p role="alert" className="text-xs text-amber-700 dark:text-amber-300">{error}</p>}
</div>
<div className="flex gap-2">
<input
value={message}
onChange={(event) => setMessage(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") void send(); }}
placeholder="Tell the assistant, e.g. “fix line 3…”"
aria-label="Message the assistant"
disabled={busy}
className="h-11 w-full rounded-md border bg-background px-3 text-sm"
/>
<Button onClick={send} disabled={busy || !message.trim()}>{busy ? "…" : "Send"}</Button>
</div>
<p className="text-xs text-muted-foreground">
The assistant can change the lines. It cannot post a delivery: receipts are confirmed by the office.
</p>
</section>
);
}

View file

@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { buildPreview, type PreviewContext } from "@/lib/dn-pipeline/ai-preview";
import { buildLineRows } from "@/lib/inbox-three/line-rows";
import { LineTable } from "./line-table";
const MESH_ITEM = "meshitem1234567";
const KERB_ITEM = "kerbitem12345678";
const context = (): PreviewContext => ({
note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "0007075669", items: [] },
pages: [],
supplier: { id: "supplierabcdefg", name: "Vula" },
po: { id: "poabcdefghijklm", purchase_order_number: "MEGCPO9448", total_value: 11748.7 },
poItems: [
{ id: MESH_ITEM, description: "MESH DIAMOND 600 x 2.5 ROLL", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
{ id: KERB_ITEM, description: "KERB 150", unit: "m", qty: 100, rate: 150, disc_percent: 0, vat_percent: 15 },
],
lines: [
{ sourceLineId: "mesh", description: "MESH DIAMOND … ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null },
{ sourceLineId: "kerb-a", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
{ sourceLineId: "kerb-b", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
{ sourceLineId: "kerb-c", description: "KERB 150", qty: 2, unit: "roll", unit_price: 2250, line_amount: 4500, product_code: null },
],
priorDeliveries: [],
priorItems: [],
});
const preview = (c: PreviewContext) => buildPreview(c, { allocations: [
{ sourceLineIds: ["mesh"], purchaseOrderItemId: MESH_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Mesh diamond roll", reviewQuestion: null },
{ sourceLineIds: ["kerb-a", "kerb-b"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
{ sourceLineIds: ["kerb-c"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Kerb", reviewQuestion: null },
] });
const poItems = [
{ id: MESH_ITEM, description: "MESH DIAMOND 600 x 2.5 ROLL", unit: "m", qty: 100, rate: 1124.35 },
{ id: KERB_ITEM, description: "KERB 150", unit: "m", qty: 100, rate: 150 },
];
const setup = () => {
const c = context();
const p = preview(c);
const rows = buildLineRows(c, p);
const onSaved = vi.fn();
const view = render(
<LineTable
rows={rows}
poItems={poItems}
batchId="batchabcdefghij"
noteId="noteabcdefghijk"
snapshotHash={p.snapshotHash}
decisionHash="decision-hash"
onSaved={onSaved}
/>,
);
return { rows, onSaved, view };
};
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
describe("Inbox 3 line table", () => {
it("numbers the rows the way the slip does", () => {
setup();
// The first row is slip line 1; the kerb row covers slip lines 2 and 3.
expect(screen.getByText("1")).toBeTruthy();
expect(screen.getByText("2+3")).toBeTruthy();
});
it("asks for the conversion when the unit is not the order's unit", () => {
setup();
fireEvent.change(screen.getAllByLabelText("Unit on the slip")[0], { target: { value: "roll" } });
expect(screen.getByText("1 roll").closest("p")?.textContent).toContain("m on the PO");
expect(screen.getByLabelText("How many m one roll is")).toBeTruthy();
});
it("keeps a ready line off the table", () => {
const c = context();
const p = preview(c);
const rows = buildLineRows(c, p);
// Mark the mesh line ready: it must not be rendered.
const ready = rows.map((row, index) => index === 0 ? { ...row, ready: true } : row);
render(
<LineTable
rows={ready}
poItems={poItems}
batchId="batchabcdefghij"
noteId="noteabcdefghijk"
snapshotHash={p.snapshotHash}
decisionHash="decision-hash"
onSaved={vi.fn()}
/>,
);
expect(screen.queryByText("1")).toBeNull();
expect(screen.getByText("2+3")).toBeTruthy();
});
it("sends every edited row in one request", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ rows: [], readyNow: [1] }), { status: 200, headers: { "Content-Type": "application/json" } }));
vi.stubGlobal("fetch", fetchMock);
const { onSaved } = setup();
fireEvent.change(screen.getByLabelText("Quantity on line 1"), { target: { value: "3" } });
fireEvent.change(screen.getAllByLabelText("Unit on the slip")[0], { target: { value: "roll" } });
fireEvent.change(screen.getByLabelText("How many m one roll is"), { target: { value: "30" } });
fireEvent.click(screen.getByText("Save these lines"));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(String(init.body));
expect(body.edits).toHaveLength(1);
expect(body.edits[0]).toMatchObject({ lines: [0], slipQty: 3, slipUnit: "roll", factor: 30 });
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({ rows: [], readyNow: [1] }));
});
});

View file

@ -0,0 +1,253 @@
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatNumber } from "@/lib/utils";
import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults";
import type { LineEdit, LineRow } from "@/lib/inbox-three/line-rows";
import { PoLinePicker, type PoLineChoice } from "./po-line-picker";
import { UnitInput } from "./unit-input";
const round2 = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100;
const money = (value: number | null) =>
value === null ? "—" : `R${formatNumber(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
/** What the office can change on one line. */
interface EditState {
poItemId: string | null;
qty: string;
unit: string;
factor: string;
price: string;
acceptExcess: boolean;
}
const initialState = (row: LineRow): EditState => ({
poItemId: row.poItemId,
qty: row.qty === null ? "" : String(row.qty),
unit: row.unit ?? "",
factor: row.factor === null ? "" : String(row.factor),
// The order rate is the price until somebody says otherwise.
price: row.poRate === null ? "" : String(row.poRate),
acceptExcess: false,
});
const same = (a: EditState, b: EditState) => JSON.stringify(a) === JSON.stringify(b);
/**
* The lines of one slip, one row each.
*
* Only the lines that still need somebody are here: a line that is ready
* has left the screen, and a posted line is on the record. Every row can be
* changed — which order line it goes to, how many, in what unit, at what
* price — and one button sends every change to the server at once.
*/
export function LineTable({
rows,
poItems,
batchId,
noteId,
snapshotHash,
decisionHash,
onSaved,
}: {
rows: LineRow[];
poItems: PoLineChoice[];
batchId: string;
noteId: string;
snapshotHash: string;
decisionHash: string;
onSaved: (result: { rows: LineRow[]; readyNow: number[] }) => void;
}) {
const [edits, setEdits] = useState<Record<string, EditState>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const open = useMemo(() => rows.filter((row) => !row.ready && !row.posted), [rows]);
const stateOf = (row: LineRow): EditState => edits[row.numbers.join("+")] ?? initialState(row);
const update = (row: LineRow, patch: Partial<EditState>) =>
setEdits((current) => ({ ...current, [row.numbers.join("+")]: { ...stateOf(row), ...patch } }));
/** What the line would book, with the numbers as they stand now. */
const live = (row: LineRow, state: EditState) => {
const item = poItems.find((candidate) => candidate.id === state.poItemId) ?? null;
const poUnit = item ? item.unit : row.poUnit;
const poRate = item ? item.rate : row.poRate;
const ordered = item ? item.qty : row.ordered;
const unit = state.unit.trim();
const differs = Boolean(poUnit && unit && !sameReceivingUnit(unit, poUnit));
const factor = differs ? (state.factor.trim() === "" ? null : Number(state.factor)) : 1;
const qty = state.qty.trim() === "" ? null : Number(state.qty);
const poQty = qty === null || factor === null ? null : round2(qty * factor);
const value = poQty === null || poRate === null ? null : round2(poQty * poRate);
const leftOnPo = ordered === null || poQty === null ? null : round2(ordered - row.priorReceived - row.booked - poQty);
return { poUnit, poRate, differs, poQty, value, leftOnPo, over: leftOnPo !== null && leftOnPo < 0 };
};
const dirty = open.filter((row) => !same(stateOf(row), initialState(row)));
const toEdit = (row: LineRow, state: EditState): LineEdit => {
const { poRate, differs } = live(row, state);
const price = state.price.trim() === "" ? null : Number(state.price);
const sameRate = price === null || (poRate !== null && Math.abs(price - poRate) < 0.005);
return {
lines: row.numbers.map((number) => number - 1),
poItemId: state.poItemId,
slipQty: state.qty.trim() === "" ? 0 : Number(state.qty),
slipUnit: state.unit.trim() || (row.unit ?? ""),
factor: differs ? (state.factor.trim() === "" ? null : Number(state.factor)) : 1,
price: sameRate ? null : price,
usePoRate: sameRate,
acceptExcess: state.acceptExcess,
reason: "",
};
};
async function save() {
if (!dirty.length || busy) return;
setBusy(true);
setError("");
try {
const response = await fetch(`/api/inbox-three/${batchId}/notes/${noteId}/lines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
edits: dirty.map((row) => toEdit(row, stateOf(row))),
snapshotHash,
decisionHash,
}),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw Error(body?.detail || "The lines could not be saved. Nothing was changed.");
setEdits({});
onSaved({ rows: body.rows ?? [], readyNow: body.readyNow ?? [] });
} catch (failure) {
setError(failure instanceof Error ? failure.message : "The lines could not be saved. Nothing was changed.");
} finally {
setBusy(false);
}
}
if (!open.length) {
return <p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">Every line on this slip is ready to confirm.</p>;
}
return (
<div className="space-y-3">
{error && <p role="alert" className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">{error}</p>}
{/* The table scrolls inside its own frame: a narrow phone must not
push the page sideways. */}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[900px] border-collapse text-sm">
<thead>
<tr className="border-b bg-muted/30 text-left text-xs uppercase tracking-wide text-muted-foreground">
<th scope="col" className="w-12 px-2 py-2">#</th>
<th scope="col" className="px-2 py-2">On the slip</th>
<th scope="col" className="px-2 py-2">Goes to PO line</th>
<th scope="col" className="px-2 py-2 text-right">Received</th>
<th scope="col" className="px-2 py-2">Unit</th>
<th scope="col" className="px-2 py-2 text-right">Price</th>
<th scope="col" className="px-2 py-2 text-right">Value</th>
<th scope="col" className="px-2 py-2 text-right">Left on PO</th>
</tr>
</thead>
<tbody>
{open.map((row) => {
const state = stateOf(row);
const numbers = live(row, state);
const over = numbers.over || row.flags.includes("over_po");
return (
<tr key={row.numbers.join("+")} className="border-b last:border-b-0 align-top">
<td className="px-2 py-3 font-semibold text-muted-foreground">{row.numbers.join("+")}</td>
<td className="px-2 py-3">
<p className="font-medium">{row.slipText}</p>
<p className="text-xs text-muted-foreground">
slip: {row.slipQty ?? "?"} {row.slipUnit ?? ""}
{row.slipPrice !== null && ` × ${money(row.slipPrice)}`}
</p>
{row.problem && <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{row.problem}</p>}
</td>
<td className="px-2 py-3">
<PoLinePicker
value={state.poItemId}
items={poItems}
onChange={(id) => update(row, { poItemId: id })}
/>
{numbers.poRate !== null && (
<p className="mt-1 text-xs text-muted-foreground">
PO says {row.ordered ?? "?"} <strong>{numbers.poUnit ?? "unit"}</strong> at {money(numbers.poRate)}
</p>
)}
</td>
<td className="px-2 py-3 text-right">
<input
type="number"
min="0"
step="0.0001"
value={state.qty}
onChange={(event) => update(row, { qty: event.target.value })}
aria-label={`Quantity on line ${row.numbers.join("+")}`}
className="h-11 w-24 rounded-md border bg-background px-2 text-right tabular-nums"
/>
</td>
<td className="px-2 py-3">
<UnitInput
unit={state.unit}
poUnit={numbers.poUnit}
factor={state.factor}
onUnitChange={(value) => update(row, { unit: value })}
onFactorChange={(value) => update(row, { factor: value })}
/>
</td>
<td className="px-2 py-3 text-right">
<input
type="number"
min="0"
step="0.01"
value={state.price}
onChange={(event) => update(row, { price: event.target.value })}
aria-label={`Price on line ${row.numbers.join("+")}`}
className="h-11 w-28 rounded-md border bg-background px-2 text-right tabular-nums"
/>
{row.slipPrice !== null && (
<p className="mt-1 text-xs text-muted-foreground">slip {money(row.slipPrice)}</p>
)}
{row.flags.includes("price_differs") && (
<Badge variant="outline" className="mt-1 border-amber-400 text-amber-700 dark:text-amber-300">price differs</Badge>
)}
</td>
<td className="px-2 py-3 text-right tabular-nums">{money(numbers.value)}</td>
<td className="px-2 py-3 text-right tabular-nums">
<span className={numbers.leftOnPo !== null && numbers.leftOnPo < 0 ? "font-semibold text-amber-700 dark:text-amber-300" : ""}>
{numbers.leftOnPo === null ? "—" : formatNumber(numbers.leftOnPo)}
</span>
{over && (
<label className="mt-1 flex items-center justify-end gap-1 text-xs text-amber-700 dark:text-amber-300">
<input
type="checkbox"
checked={state.acceptExcess}
onChange={(event) => update(row, { acceptExcess: event.target.checked })}
aria-label={`Accept over PO on line ${row.numbers.join("+")}`}
/>
accept over PO
</label>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Unit list: each · roll · m · m² · m³ · kg · 5kg bag · ton · length · box · pack. Change the unit and the value and &ldquo;left on PO&rdquo; recalculate.
</p>
<Button onClick={save} disabled={busy || !dirty.length}>
{busy ? "Saving…" : "Save these lines"}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Topbar } from "@/components/layout/sidebar";
import { Button } from "@/components/ui/button";
import { pbAdmin } from "@/lib/pb";
import { getSiteScope } from "@/lib/site-scope-server";
import { filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope";
import { buildInboxRows } from "@/lib/inbox-two/model";
import { requirePageUser } from "@/lib/auth/guard";
import { canSeeInboxThree } from "@/lib/auth/access";
import { InboxTwoView } from "../inbox-2/inbox-two-view";
export const dynamic = "force-dynamic";
/**
* Inbox 3: the office's list of slips.
*
* The same records as the other inboxes, seen from the office's side: the
* slips whose lines still need sorting, and how much of each one is ready.
*/
export default async function InboxThreePage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const user = await requirePageUser("financial");
// Inbox 3 is the office's screen while it is being tested.
if (!canSeeInboxThree(user)) notFound();
const scope = await getSiteScope(await searchParams, "financial");
const pb = await pbAdmin();
const batches = await pb.collection("dn_batches").getFullList({ expand: "site", sort: "-uploaded_at", ...filterOpt(siteFilter(scope)) });
const notes = await pb.collection("dn_batch_notes").getFullList({ expand: "supplier,purchase_order", sort: "-created" });
const rows = buildInboxRows(batches, notes, { base: "/dashboard/financial/delivery-notes/inbox-3" });
const site = scope.mode === "single" ? scope.active[0]?.id : "all";
return <>
<Topbar
title="Delivery inbox"
subtitle="Inbox 3 · Slip first: the lines that still need sorting"
right={<Button asChild variant="outline" size="sm"><Link href={`/dashboard/financial/delivery-notes/inbox-2?site=${site}`}>Switch to Inbox 2</Link></Button>}
/>
<InboxTwoView rows={rows} now={new Date().toISOString()} inbox="inbox-3" />
</>;
}

View file

@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PoLineChoice {
id: string;
description: string;
unit: string;
qty: number | null;
rate: number | null;
}
/**
* Choosing the order line a slip line belongs to.
*
* The order can have hundreds of lines, so this is a search box over the
* order's own lines — with the line's quantity, unit and rate, because the
* office is usually looking for one of them. "Not on this PO" is an answer
* too: plenty of what arrives is not on the order at all.
*/
export function PoLinePicker({
value,
items,
onChange,
disabled,
}: {
value: string | null;
items: PoLineChoice[];
onChange: (id: string | null) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const selected = items.find((item) => item.id === value) ?? null;
const query = search.trim().toLowerCase();
const matches = query
? items.filter((item) => `${item.description} ${item.unit}`.toLowerCase().includes(query))
: items;
return (
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
className="flex min-h-11 w-full items-center justify-between gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm disabled:opacity-60"
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.description : value ? "Not on this PO" : "Search PO lines…"}
</span>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{open && (
<div className="absolute z-20 mt-1 w-full min-w-[260px] rounded-md border bg-card p-2 shadow-lg">
<div className="flex items-center gap-2">
<input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Description, unit…"
aria-label="Search the order lines"
className="h-10 w-full rounded border bg-background px-2 text-sm"
/>
<button type="button" aria-label="Close the order line list" onClick={() => setOpen(false)} className="text-muted-foreground">
<X className="h-4 w-4" />
</button>
</div>
<ul className="mt-2 max-h-64 space-y-1 overflow-y-auto">
{matches.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => { onChange(item.id); setOpen(false); setSearch(""); }}
className={cn("w-full rounded px-2 py-2 text-left text-sm hover:bg-muted", item.id === value && "bg-muted")}
>
<span className="block font-medium">{item.description}</span>
<span className="block text-xs text-muted-foreground">
{item.qty ?? "?"} {item.unit || "unit"} · R{item.rate ?? "?"}
</span>
</button>
</li>
))}
{!matches.length && <li className="px-2 py-2 text-sm text-muted-foreground">No order line matches that.</li>}
<li>
<button
type="button"
onClick={() => { onChange(null); setOpen(false); setSearch(""); }}
className="w-full rounded px-2 py-2 text-left text-sm text-muted-foreground hover:bg-muted"
>
— not on this PO —
</button>
</li>
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { LineRow } from "@/lib/inbox-three/line-rows";
/**
* The lines that are ready and waiting for the office's word.
*
* They are out of the table on purpose: a line that needs nothing is not
* work. They are still listed here, because a person should be able to see
* what is being confirmed — and to confirm it.
*/
export function ReadyBanner({
lines,
batchId,
noteId,
snapshotHash,
decisionHash,
onPosted,
}: {
lines: LineRow[];
batchId: string;
noteId: string;
snapshotHash: string;
decisionHash: string;
onPosted?: () => void;
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
if (!lines.length) return null;
async function confirm() {
if (busy) return;
setBusy(true);
setError("");
try {
const response = await fetch(`/api/dn-batches/${batchId}/notes/${noteId}/matching`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "post_ready", snapshotHash, decisionHash }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw Error(body?.detail || "These lines could not be confirmed.");
onPosted?.();
} catch (failure) {
setError(failure instanceof Error ? failure.message : "These lines could not be confirmed.");
} finally {
setBusy(false);
}
}
return (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 p-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100">
<p className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>✓</span>
<strong>{lines.length} lines sent to Ready to confirm</strong>
<span className="text-emerald-800/80 dark:text-emerald-200/80">
{lines.map((line) => line.slipText.split(" + ")[0]).join(" · ")}
</span>
<button type="button" onClick={() => setOpen((current) => !current)} className="underline underline-offset-4">
{open ? "hide" : "show"}
</button>
</p>
{open && (
<div className="mt-3 space-y-2">
<ul className="space-y-1">
{lines.map((line) => (
<li key={line.numbers.join("+")} className="flex flex-wrap justify-between gap-2 text-xs">
<span>{line.numbers.join("+")} · {line.slipText}</span>
<span className="tabular-nums">
{line.poQty ?? line.qty ?? "?"} {line.unit ?? ""} · {line.value !== null ? `R${line.value}` : ""}
</span>
</li>
))}
</ul>
{error && <p role="alert" className="text-xs text-amber-800 dark:text-amber-200">{error}</p>}
<Button size="sm" onClick={confirm} disabled={busy}>{busy ? "Confirming…" : "Confirm these lines"}</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,66 @@
"use client";
import { UNIT_SUGGESTIONS } from "@/lib/inbox-three/line-rows";
/**
* The unit on a slip line, and the conversion it needs.
*
* A roll of mesh on an order written in metres is the question this screen
* exists for: the unit is free text, and when it does not match the order's
* unit the conversion is asked for right here, in the same words the office
* would use — "one roll is how many metres?".
*/
export function UnitInput({
unit,
poUnit,
factor,
onUnitChange,
onFactorChange,
disabled,
}: {
unit: string;
poUnit: string | null;
factor: string;
onUnitChange: (value: string) => void;
onFactorChange: (value: string) => void;
disabled?: boolean;
}) {
const differs = Boolean(poUnit && unit.trim() && poUnit.trim().toLowerCase() !== unit.trim().toLowerCase());
return (
<div className="space-y-2">
<div>
<input
list="inbox-three-unit-suggestions"
value={unit}
onChange={(event) => onUnitChange(event.target.value)}
placeholder="each, roll, m…"
maxLength={40}
disabled={disabled}
aria-label="Unit on the slip"
className="h-11 w-full rounded-md border bg-background px-3 text-sm"
/>
<datalist id="inbox-three-unit-suggestions">
{UNIT_SUGGESTIONS.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
</div>
{differs && (
<p className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>1 {unit.trim()}</span>
<span>=</span>
<input
type="number"
min="0"
step="0.0001"
value={factor}
onChange={(event) => onFactorChange(event.target.value)}
disabled={disabled}
aria-label={`How many ${poUnit} one ${unit.trim()} is`}
placeholder="__"
className="h-9 w-20 rounded border bg-background px-2 text-sm tabular-nums"
/>
<span>{poUnit} on the PO</span>
</p>
)}
</div>
);
}

View file

@ -34,4 +34,19 @@ describe('Inbox 2 work list', () => {
expect(filterInboxRows(rows, 'ready', 'global')).toHaveLength(1);
expect(filterInboxRows(rows, 'ready', 'hills')).toHaveLength(0);
});
it('counts the lines that are ready and the lines still open', () => {
const allocation = (id: string, over: Partial<Record<string, unknown>>) => ({ sourceLineIds: [id], posted: false, status: 'proposed', netAmount: 100, reviewReasons: [], quantityExcess: null, ...over });
const rows = buildInboxRows([batch], [{ ...note, reconciliation_preview: { allocations: [
allocation('a', {}), allocation('b', {}), allocation('c', { status: 'review', netAmount: null, reviewReasons: ['Confirm the PO line'] }),
] } }]);
expect(rows[0]).toMatchObject({ readyLines: 2, openLines: 1 });
});
it('shows a part-done slip on both the ready and the attention tab', () => {
const allocation = (id: string, over: Partial<Record<string, unknown>>) => ({ sourceLineIds: [id], posted: false, status: 'proposed', netAmount: 100, reviewReasons: [], quantityExcess: null, ...over });
const rows = buildInboxRows([batch], [{ ...note, reconciliation_preview: { allocations: [
allocation('a', {}), allocation('b', {}), allocation('c', { status: 'review', netAmount: null, reviewReasons: ['Confirm the PO line'] }),
] } }]);
expect(filterInboxRows(rows, 'ready', '')).toHaveLength(1);
expect(filterInboxRows(rows, 'attention', '')).toHaveLength(1);
});
});

View file

@ -10,19 +10,37 @@ export interface InboxRow {
number: string; supplier: string; site: string; siteId: string; po: string;
question: string; summary: string; action: string; since: string; pages: number;
net: number | null; href: string;
/** Lines that can be confirmed as they stand. */
readyLines: number;
/** Lines still waiting for somebody to sort them out. */
openLines: number;
}
type Row = Record<string, unknown>;
const obj = (v: unknown): Row => v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {};
const str = (v: unknown) => typeof v === 'string' ? v : '';
/**
* Does this row belong on this tab?
*
* A slip that is half done belongs on two: the lines that are ready can be
* confirmed now, and the lines that are still open still need somebody.
* Hiding it behind one tab would bury half the work.
*/
export function rowInCategory(row: InboxRow, category: InboxCategory): boolean {
if (row.category === category) return true;
const partDone = row.readyLines > 0 && row.openLines > 0;
return partDone && (category === 'ready' || category === 'attention');
}
/** One source of counts and rows; batches remain visible until their notes are usable. */
export function buildInboxRows(batches: Row[], notes: Row[]): InboxRow[] {
export function buildInboxRows(batches: Row[], notes: Row[], options: { base?: string } = {}): InboxRow[] {
const rows: InboxRow[] = [];
const base = options.base ?? '/dashboard/financial/delivery-notes/inbox-2';
for (const batch of batches) {
if (batch.status === 'dismissed') continue;
const batchId = str(batch.id), stage = displayBatchStage(str(batch.status), str(batch.stage));
const site = str(obj(obj(batch.expand).site).name) || 'Site not recorded';
const common = { batchId, site, siteId: str(batch.site), since: str(batch.uploaded_at || batch.created), net: null, po: '', question: '' };
const common = { batchId, site, siteId: str(batch.site), since: str(batch.uploaded_at || batch.created), net: null, po: '', question: '', readyLines: 0, openLines: 0 };
if (stage !== 'complete') {
const failed = stage === 'failed', grouping = stage === 'split_review';
// Not yet picked up by the tick: say so rather than claim it is being read.
@ -39,13 +57,21 @@ export function buildInboxRows(batches: Row[], notes: Row[]): InboxRow[] {
const category: InboxCategory = progress.step === 'posted' ? 'completed' : progress.step === 'dismissed' ? 'aside' : open ? 'attention' : progress.step === 'ready_to_post' ? 'ready' : note.stage === 'matching' ? 'processing' : 'attention';
const expanded = obj(note.expand), refs = obj(note.manual_overrides).order_references;
const money = previewMoney((note.reconciliation_preview as ProcessingPreview | null) ?? null);
// How many lines are ready to confirm, and how many are still open.
// The same count the board and the delivery screen read.
const preview = (note.reconciliation_preview as ProcessingPreview | null) ?? null;
const openLines = preview && Array.isArray(preview.allocations)
? preview.allocations.filter((allocation) => !allocation.posted).length
: 0;
const readyLines = Math.min(progress.readyLines, openLines);
rows.push({ ...common, id: str(note.id), kind: 'document', category,
readyLines, openLines: Math.max(0, openLines - readyLines),
number: str(note.doc_number || note.delivery_note_number) || 'Reference not read', supplier: str(obj(expanded.supplier).name || note.supplier_name_raw) || 'Supplier to confirm',
po: str(obj(expanded.purchase_order).purchase_order_number) || (Array.isArray(refs) ? refs.filter(v => typeof v === 'string').join(', ') : '') || str(note.po_number_raw),
question: open?.question || '', summary: open?.question || (category === 'ready' ? 'Checked and prepared. Please confirm the delivery.' : category === 'completed' ? 'Delivery recorded.' : category === 'aside' ? 'Set aside from receiving.' : progress.shortReason || 'Check the delivery details.'),
action: open ? 'Answer question' : category === 'ready' ? 'Check & confirm' : category === 'completed' || category === 'aside' ? 'View record' : 'Review with assistant',
since: open?.askedAt || str(note.created) || common.since, pages: Array.isArray(note.pages) ? note.pages.length : 0,
net: money?.net ?? null, href: `/dashboard/financial/delivery-notes/inbox-2/${str(note.id)}?site=${encodeURIComponent(str(batch.site))}` });
net: money?.net ?? null, href: `${base}/${str(note.id)}?site=${encodeURIComponent(str(batch.site))}` });
}
}
return rows;
@ -53,6 +79,6 @@ export function buildInboxRows(batches: Row[], notes: Row[]): InboxRow[] {
export function filterInboxRows(rows: InboxRow[], category: InboxCategory, search: string): InboxRow[] {
const query = search.trim().toLocaleLowerCase();
return rows.filter(row => row.category === category && (!query || `${row.number} ${row.supplier} ${row.site} ${row.po} ${row.question}`.toLocaleLowerCase().includes(query)))
return rows.filter(row => rowInCategory(row, category) && (!query || `${row.number} ${row.supplier} ${row.site} ${row.po} ${row.question}`.toLocaleLowerCase().includes(query)))
.sort((a, b) => category === 'completed' ? b.since.localeCompare(a.since) : a.since.localeCompare(b.since));
}