WFDASH-WEBAPP/src/lib/delivery-note-stage.test.ts
Dave c0e6ad2099 fix(delivery-notes): merge cancelled into completed, and fix the search
Tabs
- Cancelled no longer has its own tab. The operator asked for it to
  sit under the done heading with a filter between the two, rather
  than spending a tab on a small cold set nobody visits. Tabs are
  now Active | Completed | All.
- Inside Completed, an always-visible Completed / Cancelled / Both
  sub-filter, each option carrying its own count. It is rendered in
  the open, not behind a click — an invisible control on this screen
  was the original "what does that do?" complaint, so a hidden
  filter would have reintroduced it. Defaults to "both" so the tab's
  count and its rows always reconcile.
- Cancelled keeps a red badge, and no row tint. It now shares a tab
  with Completed, so the two must be distinguishable at a glance
  without opening each row.

Search (was quietly broken)
- It only covered `delivery_note_number` and `notes`. Customer name,
  company and order number were NOT searched — so "what's
  outstanding for Acme", the first thing anyone types on a list
  grouped by company, returned nothing.
- The search term was never escaped into the PocketBase filter,
  while the `repId` clause directly below it escapes its quotes. A
  customer called `Acme "Ltd"`, or any stray double quote, produced
  a malformed filter and a list that failed to load.
- Search is now client-side over the already-fetched notes, which
  carry customer / company / order expanded. Covers DN number, order
  number, customer, company, the customer's own PO and notes. Every
  word in a multi-word query must match somewhere on the row, so
  "acme bolt" narrows rather than returning nothing. No round trip
  per keystroke.

Also removed
- Dead pagination state. The page fetched a single wide page and
  handed the DataTable page={1} totalPages={1} with a no-op
  onPageChange, so page/setPage/totalPages could never do anything.
  That also retires the `setPage(1)` the search handler was calling.

Empty state now distinguishes "nothing matches your search" (and
names what is searchable) from "nothing on this tab".

Tests
- 30 in delivery-note-stage.test.ts, covering the search matcher and
  the tab/sub-filter bucketing. Verified they bite by reverting the
  search to its old two-field coverage: 5 fail.
- matchesDoneFilter refuses an in-flight status even under "both".
  It didn't need to — the filter only runs inside the Completed tab —
  but a function that would return a draft for "both" relies on its
  call site staying correct, so it now guards itself.

Full suite: 3630 passing. tsc clean. No new lint warnings.
2026-10-04 23:29:48 +02:00

263 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
deriveDnStage,
dnStageTint,
dnTabForStatus,
hasSignedCopy,
isDoneStatus,
matchesDeliveryNoteSearch,
matchesDoneFilter,
} from "./delivery-note-stage";
import type { DeliveryNote, DeliveryNoteStatus } from "./types";
/**
* These tests exist because the two easy ways to get the colour ladder
* wrong are both silent: a `signed_copy[0]` check that returns the
* first CHARACTER of a string-shaped PB field, and a precedence
* order that lets "has a quote" paint a row green. Neither throws.
* They just paint the screen wrong.
*/
const note = (
status: DeliveryNoteStatus,
signed_copy?: string | string[],
): Pick<DeliveryNote, "status" | "signed_copy"> => ({ status, signed_copy });
describe("hasSignedCopy", () => {
it("handles PB's single-file STRING shape", () => {
// The regression this guards: `signed_copy[0]` on a string is the
// first character, so the old array-style check reported "no
// signed copy" for every note that actually had one.
expect(hasSignedCopy({ signed_copy: "Acme-DN26071401.pdf" })).toBe(true);
});
it("handles the multi-file ARRAY shape", () => {
expect(hasSignedCopy({ signed_copy: ["a.pdf", "b.pdf"] })).toBe(true);
});
it("handles the EMPTY ARRAY shape", () => {
expect(hasSignedCopy({ signed_copy: [] })).toBe(false);
});
it("treats an empty string and undefined as no signed copy", () => {
// The two shapes PB actually returns when nothing is attached.
expect(hasSignedCopy({ signed_copy: "" })).toBe(false);
expect(hasSignedCopy({})).toBe(false);
});
it("does not trim — a whitespace string counts as attached", () => {
// Documenting the boundary rather than asserting a stricter
// contract than the domain has. `hasFile` answers "is anything
// attached?" via string length, so `" "` is truthy. That is
// unreachable in practice: PB never writes whitespace to a file
// field — `maxSelect: 1` returns `""` or a real filename. We
// delegate to the shared helper precisely so there is ONE
// implementation of this rule, and we don't want a silent
// divergence from it appearing here.
expect(hasSignedCopy({ signed_copy: " " })).toBe(true);
});
});
describe("deriveDnStage", () => {
it("gives po_received the green even when a quote and signed copy exist", () => {
// po_received is the only state with an action the operator has to
// take. It must win over the earlier stages or the actionable row
// loses its colour to a less useful one.
expect(
deriveDnStage(note("po_received", "signed.pdf"), true),
).toBe("ready_to_invoice");
expect(deriveDnStage(note("po_received"), true)).toBe("ready_to_invoice");
});
it("tints blue for a quote with no signed copy", () => {
expect(deriveDnStage(note("sent", "signed.pdf"), true)).toBe("quoted");
expect(deriveDnStage(note("sent"), true)).toBe("quoted");
});
it("tints yellow for a signed copy with no quote", () => {
expect(deriveDnStage(note("draft", "signed.pdf"), false)).toBe("signed");
});
it("leaves a bare draft untinted", () => {
expect(deriveDnStage(note("draft"), false)).toBe("draft");
});
it("files invoiced and completed as done", () => {
expect(deriveDnStage(note("invoiced", "s.pdf"), true)).toBe("invoiced");
expect(deriveDnStage(note("completed"), false)).toBe("invoiced");
});
it("files cancelled as cancelled regardless of attachments", () => {
expect(deriveDnStage(note("cancelled", "s.pdf"), true)).toBe("cancelled");
});
});
describe("dnStageTint", () => {
it("tints the three in-progress stages and nothing else", () => {
expect(dnStageTint("draft")).toBeNull();
expect(dnStageTint("invoiced")).toBeNull();
expect(dnStageTint("cancelled")).toBeNull();
expect(dnStageTint("signed")).not.toBeNull();
expect(dnStageTint("quoted")).not.toBeNull();
expect(dnStageTint("ready_to_invoice")).not.toBeNull();
});
it("gives every tinted stage its own colour", () => {
const stripes = (["signed", "quoted", "ready_to_invoice"] as const).map(
(s) => dnStageTint(s)!.stripe,
);
expect(new Set(stripes).size).toBe(3);
});
it("explains itself — every tint carries a human-readable reason", () => {
// A colour with no stated reason is the "mystery toggle" problem
// this whole change set exists to fix.
for (const stage of ["signed", "quoted", "ready_to_invoice"] as const) {
expect(dnStageTint(stage)!.label.length).toBeGreaterThan(5);
}
});
});
describe("dnTabForStatus", () => {
it("keeps only genuinely-outstanding work in the active bucket", () => {
// invoiced used to sit in Active, which meant the active list
// never drained and finished work stayed in the operator's face.
expect(dnTabForStatus("draft")).toBe("active");
expect(dnTabForStatus("sent")).toBe("active");
expect(dnTabForStatus("loaded")).toBe("active");
expect(dnTabForStatus("po_received")).toBe("active");
});
it("folds invoiced, completed AND cancelled into one done bucket", () => {
// Cancelled used to have its own tab. The operator asked for it
// to live under the done heading with a filter between them.
expect(dnTabForStatus("invoiced")).toBe("done");
expect(dnTabForStatus("completed")).toBe("done");
expect(dnTabForStatus("cancelled")).toBe("done");
});
});
describe("matchesDoneFilter", () => {
it("'both' shows every done note", () => {
expect(matchesDoneFilter("invoiced", "both")).toBe(true);
expect(matchesDoneFilter("completed", "both")).toBe(true);
expect(matchesDoneFilter("cancelled", "both")).toBe(true);
});
it("'completed' hides cancelled but keeps both finished states", () => {
expect(matchesDoneFilter("invoiced", "completed")).toBe(true);
expect(matchesDoneFilter("completed", "completed")).toBe(true);
expect(matchesDoneFilter("cancelled", "completed")).toBe(false);
});
it("'cancelled' shows only cancelled", () => {
expect(matchesDoneFilter("cancelled", "cancelled")).toBe(true);
expect(matchesDoneFilter("completed", "cancelled")).toBe(false);
expect(matchesDoneFilter("invoiced", "cancelled")).toBe(false);
});
it("never lets an in-flight status through, whatever the filter", () => {
// Guard: the filter lives INSIDE the done tab, so this should be
// impossible in the UI. If it ever is, we'd show work-in-progress
// in a tab labelled Completed.
for (const f of ["both", "completed", "cancelled"] as const) {
expect(matchesDoneFilter("draft", f)).toBe(false);
expect(matchesDoneFilter("po_received", f)).toBe(false);
}
});
});
describe("isDoneStatus", () => {
it("treats cancelled as done — the work finished, one way or another", () => {
expect(isDoneStatus("cancelled")).toBe(true);
expect(isDoneStatus("completed")).toBe(true);
expect(isDoneStatus("invoiced")).toBe(true);
expect(isDoneStatus("draft")).toBe(false);
expect(isDoneStatus("sent")).toBe(false);
expect(isDoneStatus("loaded")).toBe(false);
expect(isDoneStatus("po_received")).toBe(false);
});
});
const row = (over: Record<string, unknown> = {}) =>
({
delivery_note_number: "DN26071401",
notes: "counter pickup",
customer_po: "PO-9931",
...over,
}) as Parameters<typeof matchesDeliveryNoteSearch>[0];
describe("matchesDeliveryNoteSearch", () => {
it("matches the DN number", () => {
expect(matchesDeliveryNoteSearch(row(), "26071401")).toBe(true);
expect(matchesDeliveryNoteSearch(row(), "26071401")).toBe(true);
});
it("matches the CUSTOMER name — the case the old server filter missed", () => {
// This is the regression that motivated moving search
// client-side: the old filter only read delivery_note_number and
// notes, so "what's outstanding for Acme" returned nothing on a
// list that is grouped by company.
const n = row({
expand: {
order: { order_number: "ORD-2607-118" },
customer: { customer: "Andrew Bester" },
company: { company: "Acme Bolts" },
},
});
expect(matchesDeliveryNoteSearch(n, "acme")).toBe(true);
expect(matchesDeliveryNoteSearch(n, "bester")).toBe(true);
});
it("matches the company and the order number", () => {
const n = row({
expand: {
order: { order_number: "ORD-2607-118" },
customer: { customer: "Andrew Bester" },
company: { company: "Acme Bolts" },
},
});
expect(matchesDeliveryNoteSearch(n, "ORD-2607-118")).toBe(true);
expect(matchesDeliveryNoteSearch(n, "acme bolts")).toBe(true);
});
it("matches the customer's own PO reference", () => {
expect(matchesDeliveryNoteSearch(row(), "PO-9931")).toBe(true);
});
it("is case-insensitive", () => {
expect(matchesDeliveryNoteSearch(row(), "COUNTER")).toBe(true);
});
it("requires EVERY word, so 'acme bolt' narrows rather than 404s", () => {
const n = row({
expand: { company: { company: "Acme Bolts" } },
});
expect(matchesDeliveryNoteSearch(n, "acme bolt")).toBe(true);
expect(matchesDeliveryNoteSearch(n, "acme missing")).toBe(false);
});
it("returns everything for an empty or whitespace query", () => {
expect(matchesDeliveryNoteSearch(row(), "")).toBe(true);
expect(matchesDeliveryNoteSearch(row(), " ")).toBe(true);
});
it("does not throw on a note with no expansions at all", () => {
// Ad-hoc counter notes can carry a customer but no order, and a
// note mid-creation can have neither. A search box that throws on
// those rows takes the whole list down.
const bare = row();
expect(() => matchesDeliveryNoteSearch(bare, "anything")).not.toThrow();
expect(matchesDeliveryNoteSearch(bare, "anything")).toBe(false);
});
it("treats a quote in the query as ordinary text, not a filter break", () => {
// The old server-side filter interpolated the term into a PB
// filter string with no escaping, so a double quote produced a
// malformed filter and a list that failed to load.
const n = row({
expand: { company: { company: 'Acme "Ltd" Bolts' } },
});
expect(matchesDeliveryNoteSearch(n, 'acme "ltd"')).toBe(true);
});
});