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.
263 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|