WFDASH-WEBAPP/src/lib/delivery-note-stage.test.ts

264 lines
10 KiB
TypeScript
Raw Normal View History

feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search The delivery-notes list was close to unusable: company groups initialised to an empty Set, so `isOpen` was false for every group and the page landed on a stack of company headers with nothing under them. The code comment claimed the opposite of what the code did. Screen - Track *collapsed* group names instead of open ones. Open is now the default with no initialisation, and it survives a list re-fetch. - Rows expand to show the real line items (description, qty, supplier, SKU, price, subtotal) via the existing /detail read model, cached after first open. Ported from /quotes, which already had this affordance. - Whole-row click removed so expanding can't be mistaken for opening; the DN number and "Open full delivery note ->" navigate instead. - Row tint as a derived progress ladder — no new stored statuses: none = draft · yellow = signed copy · blue = quoted · green = po_received `invoiced` leaves the Active tab, so Active is now genuinely what is outstanding. Hovering the status badge explains the colour. - DataTable gained an optional `selectionHeaderLabel`; the checkboxes feed one action (Combine) and now say so. Stock search - The create-delivery-note dialog was `getFullList`-ing every stock item with expand=supplier on each open, before a character was typed. Now a debounced typeahead against /api/v1/stock-items/search. - Deliberately NOT the existing quick-draft search route: that one is served with an admin client and no session check, so its result isn't gated on the listRule. The new route uses assertBrowserSession. Fixes - The PDF preview modal was clamped to 384px wide and 900px tall despite passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a *variant*, so twMerge kept both and the variant won on cascade order (verified against the compiled stylesheet: 88,536 vs 258,517). Both preview variants now repeat the `sm:` prefix so the conflict resolves. - Expand-panel fetch was missing the Authorization header. Dashboard auth lives in localStorage, so route handlers cannot read it from cookies and the request 401'd. A 401 now reports an expired session instead of a bare status code. Notes - 25 new unit tests. The stage ladder and the PB filter construction are the two places where a silent wrong answer looks like a UI bug. - Unauthenticated write routes on delivery-notes (create, combine, create-quote, customer-po, mark-invoiced) have no session check. Left as-is and reported separately — locking them is an auth-behaviour change.
2026-10-04 18:40:13 +00:00
import { describe, it, expect } from "vitest";
import {
deriveDnStage,
dnStageTint,
dnTabForStatus,
hasSignedCopy,
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 21:29:48 +00:00
isDoneStatus,
matchesDeliveryNoteSearch,
matchesDoneFilter,
feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search The delivery-notes list was close to unusable: company groups initialised to an empty Set, so `isOpen` was false for every group and the page landed on a stack of company headers with nothing under them. The code comment claimed the opposite of what the code did. Screen - Track *collapsed* group names instead of open ones. Open is now the default with no initialisation, and it survives a list re-fetch. - Rows expand to show the real line items (description, qty, supplier, SKU, price, subtotal) via the existing /detail read model, cached after first open. Ported from /quotes, which already had this affordance. - Whole-row click removed so expanding can't be mistaken for opening; the DN number and "Open full delivery note ->" navigate instead. - Row tint as a derived progress ladder — no new stored statuses: none = draft · yellow = signed copy · blue = quoted · green = po_received `invoiced` leaves the Active tab, so Active is now genuinely what is outstanding. Hovering the status badge explains the colour. - DataTable gained an optional `selectionHeaderLabel`; the checkboxes feed one action (Combine) and now say so. Stock search - The create-delivery-note dialog was `getFullList`-ing every stock item with expand=supplier on each open, before a character was typed. Now a debounced typeahead against /api/v1/stock-items/search. - Deliberately NOT the existing quick-draft search route: that one is served with an admin client and no session check, so its result isn't gated on the listRule. The new route uses assertBrowserSession. Fixes - The PDF preview modal was clamped to 384px wide and 900px tall despite passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a *variant*, so twMerge kept both and the variant won on cascade order (verified against the compiled stylesheet: 88,536 vs 258,517). Both preview variants now repeat the `sm:` prefix so the conflict resolves. - Expand-panel fetch was missing the Authorization header. Dashboard auth lives in localStorage, so route handlers cannot read it from cookies and the request 401'd. A 401 now reports an expired session instead of a bare status code. Notes - 25 new unit tests. The stage ladder and the PB filter construction are the two places where a silent wrong answer looks like a UI bug. - Unauthenticated write routes on delivery-notes (create, combine, create-quote, customer-po, mark-invoiced) have no session check. Left as-is and reported separately — locking them is an auth-behaviour change.
2026-10-04 18:40:13 +00:00
} 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");
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 21:29:48 +00:00
});
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);
feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search The delivery-notes list was close to unusable: company groups initialised to an empty Set, so `isOpen` was false for every group and the page landed on a stack of company headers with nothing under them. The code comment claimed the opposite of what the code did. Screen - Track *collapsed* group names instead of open ones. Open is now the default with no initialisation, and it survives a list re-fetch. - Rows expand to show the real line items (description, qty, supplier, SKU, price, subtotal) via the existing /detail read model, cached after first open. Ported from /quotes, which already had this affordance. - Whole-row click removed so expanding can't be mistaken for opening; the DN number and "Open full delivery note ->" navigate instead. - Row tint as a derived progress ladder — no new stored statuses: none = draft · yellow = signed copy · blue = quoted · green = po_received `invoiced` leaves the Active tab, so Active is now genuinely what is outstanding. Hovering the status badge explains the colour. - DataTable gained an optional `selectionHeaderLabel`; the checkboxes feed one action (Combine) and now say so. Stock search - The create-delivery-note dialog was `getFullList`-ing every stock item with expand=supplier on each open, before a character was typed. Now a debounced typeahead against /api/v1/stock-items/search. - Deliberately NOT the existing quick-draft search route: that one is served with an admin client and no session check, so its result isn't gated on the listRule. The new route uses assertBrowserSession. Fixes - The PDF preview modal was clamped to 384px wide and 900px tall despite passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a *variant*, so twMerge kept both and the variant won on cascade order (verified against the compiled stylesheet: 88,536 vs 258,517). Both preview variants now repeat the `sm:` prefix so the conflict resolves. - Expand-panel fetch was missing the Authorization header. Dashboard auth lives in localStorage, so route handlers cannot read it from cookies and the request 401'd. A 401 now reports an expired session instead of a bare status code. Notes - 25 new unit tests. The stage ladder and the PB filter construction are the two places where a silent wrong answer looks like a UI bug. - Unauthenticated write routes on delivery-notes (create, combine, create-quote, customer-po, mark-invoiced) have no session check. Left as-is and reported separately — locking them is an auth-behaviour change.
2026-10-04 18:40:13 +00:00
});
});