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): 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, 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]; }