WFDASH-WEBAPP/src/lib/delivery-note-stage.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

215 lines
7.8 KiB
TypeScript

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);
}
/**
* 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.
*/
export function dnTabForStatus(status: DeliveryNoteStatus): "active" | "done" {
return isDoneStatus(status) ? "done" : "active";
}
/**
* 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];
}