feat(dn): inbox 3 slip-first matching screen
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
1514d514b3
commit
402824e018
14 changed files with 1066 additions and 10 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. “line 3 is the bailing wire”.</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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] }));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 “left on PO” recalculate.
|
||||
</p>
|
||||
<Button onClick={save} disabled={busy || !dirty.length}>
|
||||
{busy ? "Saving…" : "Save these lines"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/app/dashboard/financial/delivery-notes/inbox-3/page.tsx
Normal file
39
src/app/dashboard/financial/delivery-notes/inbox-3/page.tsx
Normal 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" />
|
||||
</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue