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

216 lines
7.8 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 { hasFile } from "@/lib/file-fields";
import type { DeliveryNote, DeliveryNoteStatus } from "@/lib/types";
/**
* Progress stage for a delivery note, derived — never stored.
*
* The delivery-notes list colours rows by stage rather than adding a
* status per state. Two reasons:
*
* 1. The lifecycle already has seven states (`DeliveryNoteStatus`)
* and they describe *how* a note got there, not *how far* along
* the operator is. "draft" and "sent" and "loaded" are all
* "in progress" as far as a person scanning a list is concerned;
* only three things change what they should do next.
* 2. A colour that is derived can't drift out of sync with the data.
* A stored status is wrong the moment someone uploads a signed
* copy out of band.
*
* The ladder, most-advanced-first:
*
* cancelled → own tab, no tint (the tab is the signal)
* invoiced → Completed tab, no tint
* ready_to_invoice → GREEN. `po_received` is the only state on this
* screen with an action the *operator* has to take
* (the "Mark invoiced" button), so it gets the one
* colour that means "this needs you now".
* quoted → BLUE. A quote exists, waiting on the customer's
* PO coming back.
* signed → YELLOW. Signed copy uploaded, nothing quoted.
* draft → NO TINT. Nothing has happened yet.
*
* Precedence is strictly ordered, not a set of independent flags:
* signed-copy and quote are not actually a sequence (a note can have
* one and not the other), and every row must land on exactly one
* colour or the signal is meaningless.
*/
export type DnStage =
| "cancelled"
| "invoiced"
| "ready_to_invoice"
| "quoted"
| "signed"
| "draft";
/**
* Does this note have an operator-uploaded signed copy?
*
* Delegates to `hasFile` in `@/lib/file-fields`, which is where the
* PocketBase string-vs-array trap for file fields is documented and
* handled. PB RETURNS a bare string when exactly one file is attached
* and an array only when several are, so `signed_copy[0]` on the
* string form yields the note's first *character* — an array-style
* check reports "no signed copy" on exactly the notes that have one.
* Both shapes have to be handled. Reusing the shared helper keeps one
* implementation of that rule in the codebase.
*/
export function hasSignedCopy(note: Pick<DeliveryNote, "signed_copy">): boolean {
return hasFile(note.signed_copy);
}
/**
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
* Sub-filter for the Completed tab.
*
* The operator asked for cancelled and completed to live together
* with a way to narrow between them, rather than as two separate
* tabs. `both` is the default so the tab's count and its contents
* always agree — a tab that says "Completed 40" and then shows 29
* rows with the other 11 hidden is the sort of thing that makes a
* list untrustworthy.
*/
export type DoneFilter = "completed" | "cancelled" | "both";
/**
* Does a status fall inside the "done" bucket (the Completed tab)?
* Cancelled counts as done: the work is finished, one way or another.
*/
export function isDoneStatus(status: DeliveryNoteStatus): boolean {
return status === "invoiced" || status === "completed" || status === "cancelled";
}
/**
* Apply the sub-filter inside the done bucket.
*
* `"both"` means "both FINISHED kinds", not "any status at all" — it
* still refuses an in-flight note. The filter currently only runs
* inside the Completed tab so nothing can leak in practice, but a
* function that would happily return a `draft` for `both` relies on
* its call site staying correct. Guarding here means reusing it
* elsewhere can't quietly put work-in-progress in a done list.
*/
export function matchesDoneFilter(
status: DeliveryNoteStatus,
filter: DoneFilter,
): boolean {
if (!isDoneStatus(status)) return false;
if (filter === "both") return true;
if (filter === "cancelled") return status === "cancelled";
return status === "invoiced" || status === "completed";
}
/**
* Client-side search over a fetched delivery note.
*
* Deliberately matches EVERY field the row displays. The previous
* server-side filter only covered `delivery_note_number` and `notes`,
* so typing a customer or company name — the first thing anyone
* types on a list grouped by company — returned nothing at all.
*
* Matching is case-insensitive substring across each field
* independently, and multi-word queries match if EVERY word appears
* somewhere on the row. That second rule matters more than it looks:
* operators type "acme bolt" meaning *Acme* AND *bolt*, and a plain
* whole-string substring test would find neither, because no single
* field contains "acme bolt".
*/
export function matchesDeliveryNoteSearch(
note: Pick<
DeliveryNote,
"delivery_note_number" | "notes" | "customer_po"
> & {
expand?: {
order?: { order_number?: string | null } | null;
customer?: { customer?: string | null } | null;
company?: { company?: string | null } | null;
};
},
query: string,
): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const haystack = [
note.delivery_note_number ?? "",
note.notes ?? "",
note.customer_po ?? "",
note.expand?.order?.order_number ?? "",
note.expand?.customer?.customer ?? "",
note.expand?.company?.company ?? "",
]
.join(" ")
.toLowerCase();
// Every word must appear somewhere on the row.
return q.split(/\s+/).every((word) => haystack.includes(word));
}
/**
* Which top-level tab a status belongs to. Kept next to
* `deriveDnStage` so the tab buckets and the colour ladder can never
* disagree about what "active" means.
*
* `cancelled` is deliberately NOT its own tab any more — the operator
* asked for it to sit alongside completed under one "done" heading
* with a filter between them, rather than as a third bucket that
* needs a tab of its own for a small, cold set.
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
*/
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
export function dnTabForStatus(status: DeliveryNoteStatus): "active" | "done" {
return isDoneStatus(status) ? "done" : "active";
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
}
/**
* Derive the stage. `hasQuote` comes from the page's pre-fetched
* quote map rather than a per-row lookup, so this stays synchronous
* and cheap enough to run inside a `rowStyle` callback.
*/
export function deriveDnStage(
note: Pick<DeliveryNote, "status" | "signed_copy">,
hasQuote: boolean,
): DnStage {
if (note.status === "cancelled") return "cancelled";
if (note.status === "invoiced" || note.status === "completed") return "invoiced";
if (note.status === "po_received") return "ready_to_invoice";
if (hasQuote) return "quoted";
if (hasSignedCopy(note)) return "signed";
return "draft";
}
/**
* The visual treatment per stage: a left-edge stripe colour and a
* background wash. `null` means "don't tint this row at all" — the
* Completed and Canceled tabs are already unambiguous, and a draft
* with nothing done yet should read as neutral rather than
* "something is wrong here".
*
* The stripes are deliberately low-alpha. Four hundred rows of
* saturated colour is noise; the point is to catch the greens in a
* long list, not to make the table look like a paint chart.
*/
const STAGE_TINT: Record<
DnStage,
{ stripe: string; wash: string; label: string } | null
> = {
ready_to_invoice: {
stripe: "rgb(34 197 94 / 0.9)",
wash: "rgb(34 197 94 / 0.07)",
label: "Ready to invoice — waiting on you",
},
quoted: {
stripe: "rgb(59 130 246 / 0.8)",
wash: "rgb(59 130 246 / 0.06)",
label: "Quote created — waiting on the customer's PO",
},
signed: {
stripe: "rgb(234 179 8 / 0.85)",
wash: "rgb(234 179 8 / 0.07)",
label: "Signed copy uploaded — not quoted yet",
},
draft: null,
invoiced: null,
cancelled: null,
};
export function dnStageTint(stage: DnStage) {
return STAGE_TINT[stage];
}