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];
|
|
|
|
|
}
|