WFDASH-WEBAPP/src/lib/item-status-ui.ts
Dave b212d0fcd8 feat(manager): a mobile orders board for the managers at /manager
A hidden, installable PWA: one link, opened on a phone, showing where
every order is. New / Processing / Completed, with a tap-to-preview
popup and the invoice/line detail Gary needs. Read-only, and NOT in
the sidebar — the route lives outside `(app)`, so `<Sidebar />` never
renders there.

The preview popup is reused, not rebuilt: the service emits
`UniversalHit`s and the page hands them to the existing
`SearchPreviewDialog`, so the manager gets the desk's quick-overview
verbatim.

Also fixes the /orders Completed tab, which was matching ZERO orders.
`delivered_at` is stamped on only 2 of 12,957 delivered lines and the
business stopped writing `delivered` on 2026-08-14 (the live pipeline
now ends at `invoiced` / `checked_in`), so the old filter never
matched. "Settled" is now defined once — `SETTLED_STATUSES` +
`SETTLED_DATE_FIELDS` — and dated per status: `invoiced_date` for
invoiced (the only properly stamped field, 2,199 lines), `delivered_at`
for delivered, `updated` only where no dedicated column exists.

`delivery_note` and `checked_in` move into `completed` as instructed,
and the standalone `post_processing` tab is retired (a DN is not a
stage of its own). `invoiced` stays in `collected` as well, so
invoiced orders don't vanish from the workflow tabs.

Completed tab: 0 -> 341 orders over 30 days, 112 over 7.
2026-10-26 23:09:01 +02:00

381 lines
16 KiB
TypeScript

/**
* Visual tokens + tab-group mapping for the item-status-driven
* orders model.
*
* Two surfaces share the same hex palette so the per-item pill and
* the tab pill always agree on colour:
*
* 1. Per-item pill (rendered next to each line in an order row)
* — driven by `ITEM_STATUS_VISUAL[itemStatus].hex`
*
* 2. Tab pill on /orders (the 6 status tabs)
* — driven by `TAB_GROUP_VISUAL[tabGroup].hex`
*
* Tabs do NOT map 1:1 to item statuses — eleven item statuses would
* be too noisy as tabs. We collapse them into six operational
* groups (see `TAB_GROUP_ITEM_STATUSES`). An order appears on tab X
* iff it has at least one item whose `item_status` belongs to that
* group; collapsing means a single item only ever belongs to one tab.
*
* White text on each hex clears WCAG AA (≥ 4.5:1) — verified for all
* values. The 500-shade variants (e.g. #3b82f6) fail contrast on
* amber/teal/emerald, so we deliberately use 600/700-shade hex.
*/
import type { ItemStatus } from "@/lib/types";
/* ============================================================================
* Per-item visual tokens
* ========================================================================== */
export type ItemStatusVisual = {
/** Hex used for the item pill fill / dot. */
hex: string;
/** Friendly label rendered inside the pill. */
label: string;
};
const FALLBACK_ITEM_STATUS_VISUAL: ItemStatusVisual = {
hex: "#475569",
label: "Unknown",
};
export const ITEM_STATUS_VISUAL: Record<ItemStatus, ItemStatusVisual> = {
pending: { hex: "#2563eb", label: "New" },
approval_needed: { hex: "#b45309", label: "Approval Needed" },
approval_rejected: { hex: "#be123c", label: "Approval Rejected" },
processed: { hex: "#b45309", label: "Processing" },
po_generated: { hex: "#b45309", label: "Processing" },
// Pre-pickup collection flow. ready_delivery ("supplier confirmed
// ready for pickup") sits BEFORE the pool — it shares the purple
// hue with collection_pool/assigned so the operator sees the
// family, but gets its own label so they don't confuse it with
// the actual `collected` status (driver has picked up).
collection_pool: { hex: "#7c3aed", label: "Collection Pool" },
assigned: { hex: "#7c3aed", label: "Collection Pool" },
ready_delivery: { hex: "#7c3aed", label: "Ready for Collection" },
collected: { hex: "#0f766e", label: "Collected" },
stock_verified: { hex: "#0f766e", label: "Collected" },
picking: { hex: "#0f766e", label: "Collected" },
delivered: { hex: "#047857", label: "Delivered" },
cancelled: { hex: "#e11d48", label: "Cancelled" },
ready_invoice: { hex: "#1d4ed8", label: "Ready for Invoice" },
invoiced: { hex: "#047857", label: "Invoiced" },
stock_booked: { hex: "#0e7490", label: "Stock Booked" },
// Post-invoice physical-delivery flow (2026-07-20).
// Each step uses a different hex so a row can be visually
// distinguished at a glance — the operator can tell
// "invoiced" (green) from "with_driver" (amber) from
// "checked_in" (teal) from "completed" (deep green)
// without reading the label.
with_driver: { hex: "#b45309", label: "With Driver" },
checked_in: { hex: "#0e7490", label: "Checked In" },
completed: { hex: "#064e3b", label: "Completed" },
// Review-modal soft-flag: the operator dropped the row from the
// order via the review modal's trash button. We keep the row on
// the order so historical reports / PO PDFs still reference it,
// but the row is hidden from the active items list. The muted
// red color matches the destructive intent without being loud.
removed: { hex: "#71717a", label: "Removed" },
// DN issued but the invoice hasn't been generated yet — operator
// action pending. Amber-brown so it doesn't blend with the
// processing (orange) or the collected (teal) families.
delivery_note: { hex: "#7c2d12", label: "Delivery Note" },
};
export function getItemStatusVisual(status: string): ItemStatusVisual {
return ITEM_STATUS_VISUAL[status as ItemStatus]
?? FALLBACK_ITEM_STATUS_VISUAL;
}
/* ============================================================================
* Tab groups (the 6 /orders filter tabs)
* ========================================================================== */
/**
* The seven tab groups. Each one is the set of item statuses that
* "live" on that tab. An order is shown on tab X iff at least one of
* its items has an item_status in `TAB_GROUP_ITEM_STATUSES[X]`.
*
* Display order matches the operational lifecycle: a brand-new item
* lands on "new"; PO'd items move to "processing"; once queued for
* collection they slide to "collection pool"; back at the office they
* move to "collected"; a settled line lands on "completed" (the only
* time-windowed tab); delivered and cancelled are terminal and have
* their own tabs so the operator can audit them without them
* drowning the active tabs.
*/
export type TabGroup =
| "new"
| "processing"
| "collection_pool"
| "collected"
| "completed"
| "delivered"
| "cancelled";
export const TAB_GROUP_ORDER: TabGroup[] = [
"new",
"processing",
"collection_pool",
"collected",
"completed",
"delivered",
"cancelled",
];
export const TAB_GROUP_ITEM_STATUSES: Record<TabGroup, ItemStatus[]> = {
new: ["pending", "approval_needed", "approval_rejected"],
processing: ["processed", "po_generated"],
// `delivery_note` used to live in its own "post_processing" tab
// ("DN issued, not yet finalised"). That tab was retired on
// 2026-10-26: a DN is not a stage of its own any more - it is a
// line that has been picked up and is on its way to settled, so it
// counts as done work for the manager's view. The tab went with it
// (it would otherwise sit permanently at zero).
//
// Pre-pickup flow - collection_pool, assigned, AND ready_delivery
// (supplier confirmed ready for pickup) all live here. ready_delivery
// used to be lumped under "collected" which made it look like the
// item had already been picked up by the driver, but it sits
// *before* the pool, not after.
collection_pool: ["collection_pool", "assigned", "ready_delivery"],
// Post-pickup, pre-terminal: stock verified, item picked, awaiting
// invoice, invoice issued, in transit with the driver. The operator
// finishes the buyer's workflow at this point; `delivered` is the
// sales/delivery confirmation.
collected: ["collected", "stock_verified", "picking",
"ready_invoice", "invoiced",
"stock_booked", "with_driver"],
// "Recently completed" - lines that have reached a settled state:
// delivered, completed, checked in (goods physically received),
// invoiced, or a delivery note issued. Time-windowed so the tab
// self-clears; the window is applied at the service layer (see
// `completedTabFilter` in orders-service.ts) - the partition here is
// just the set of statuses that *qualify*, not the freshness check.
//
// `invoiced` and `delivery_note` also appear in the `collected`
// group. That is deliberate: `itemStatusToTabGroup` resolves an
// ambiguous status to the FIRST group in `TAB_GROUP_ORDER`, so
// these land on `collected` (the workflow tab the office works
// from) while the completed tab still sees them through its own
// windowed filter. An order is allowed to appear on more than one
// tab - `delivered` has always worked that way.
completed: ["delivered", "completed", "checked_in",
"delivery_note", "invoiced"],
delivered: ["delivered"],
// `removed` joins `cancelled` — items struck from an order live with
// the other non-completions so the active workflow tabs don't carry
// them. The /orders/archive page surfaces both buckets.
cancelled: ["cancelled", "removed"],
};
/**
* PB filter fragment for "items belonging to this tab group". PB
* doesn't have a set-membership operator on `IN`, so we OR a series
* of `item_status = "..."` clauses together — same approach the
* old per-status counts used.
*/
export function tabGroupFilter(tab: TabGroup): string {
return TAB_GROUP_ITEM_STATUSES[tab]
.map((s) => `item_status = "${s}"`)
.join(" || ");
}
/**
* Map an individual item status to the single tab group it belongs
* to. The grouping is a partition (every status lands in exactly one
* tab) — derived from `TAB_GROUP_ITEM_STATUSES`.
*
* Every member of the `ItemStatus` union MUST be listed in
* `TAB_GROUP_ITEM_STATUSES`. If you add a status to the union and
* forget, this function throws rather than silently mis-bucketing —
* the bug would otherwise be invisible: the item would render on the
* wrong tab AND drop out of search counts (see `TAB_STATUSES`).
*/
export function itemStatusToTabGroup(s: ItemStatus): TabGroup {
for (const tab of TAB_GROUP_ORDER) {
if (TAB_GROUP_ITEM_STATUSES[tab].includes(s)) return tab;
}
throw new Error(
`itemStatusToTabGroup: status "${s}" is not mapped to any tab. ` +
`Add it to TAB_GROUP_ITEM_STATUSES in src/lib/item-status-ui.ts.`,
);
}
/* ============================================================================
* Tab-group visual tokens
* ========================================================================== */
export type TabGroupVisual = {
hex: string;
label: string;
};
export const TAB_GROUP_VISUAL: Record<TabGroup, TabGroupVisual> = {
new: { hex: "#2563eb", label: "New" },
processing: { hex: "#b45309", label: "Processing" },
// Purple for the pre-pickup bucket: the order is with the supplier
// (or waiting for the driver) and nothing is happening on our side.
collection_pool: { hex: "#7c3aed", label: "Collection Pool" },
collected: { hex: "#0f766e", label: "Collected" },
// Distinct from `delivered` (#047857) so the operator can tell at
// a glance "this just finished in the last 30 days" from
// "this has been archived for a while". Deeper, more forest-green
// hex signals "settled but recent".
completed: { hex: "#064e3b", label: "Completed" },
delivered: { hex: "#047857", label: "Delivered" },
cancelled: { hex: "#e11d48", label: "Cancelled" },
};
/* ============================================================================
* Settled statuses — "this line is finished"
* ========================================================================== */
/**
* The statuses that mean a line has reached the end of its journey.
*
* `delivered` and `completed` were the original pair. `checked_in`
* (goods physically received) and `invoiced` (billed) joined them on
* 2026-10-26 because the business stopped writing `delivered` on
* 2026-08-14 — the live pipeline now ends at `invoiced` /
* `checked_in`, and a completed-tab that only looked at `delivered`
* matched zero orders for six weeks.
*
* `delivery_note` (a DN issued against the order) joins them for the
* same reason: it is a line that has been picked up and is on its way
* to settled, not a stage that needs its own tab.
*/
export const SETTLED_STATUSES: ItemStatus[] = [
"delivered",
"completed",
"checked_in",
"invoiced",
"delivery_note",
];
/**
* Which PB date field carries the "when did this line finish" stamp,
* per settled status.
*
* The choice matters because the fields are NOT equally populated on
* live data (measured 2026-10-26, 12,957 delivered lines):
*
* - `delivered_at` — written by `markOrderDelivered`. Populated on
* 2 lines. The other 12,955 are legacy rows
* whose status was set without a stamp.
* - `invoiced_date` — 2,199 lines. The ONLY properly stamped
* settlement date in the schema.
* - `updated` — set on every write. A safe fallback: it is
* the moment the row last changed, which for a
* settled line is at least close to when it
* settled.
*
* A `null` entry means "this status has no dedicated stamp — use
* `updated`". `delivered` lists BOTH because its dedicated field is
* mostly empty and its fallback is the only thing that works.
*/
export type SettledDateSpec = {
/**
* The status has a DEDICATED stamp field. The row counts as settled
* only when that field is set and falls inside the window.
*
* A row whose dedicated field is empty is NOT recent — it is
* un-dated, and un-dated belongs to the archive, not to "completed
* this week". (12,955 of the 12,957 delivered lines have an empty
* `delivered_at`; their `updated` is the migration timestamp, so
* treating them as recently settled would resurface archived work
* every time a row was touched.)
*/
primary?: string;
/**
* The status has NO dedicated stamp field in the schema, so `updated`
* — which PB bumps on every write — is the only signal available.
*/
fallback?: string;
};
export const SETTLED_DATE_FIELDS: Partial<Record<ItemStatus, SettledDateSpec>> = {
delivered: { primary: "delivered_at" },
invoiced: { primary: "invoiced_date" },
completed: { fallback: "updated" },
checked_in: { fallback: "updated" },
delivery_note: { fallback: "updated" },
};
/* ============================================================================
* Manager pipeline groups — the three buckets on /manager
* ========================================================================== */
/**
* The coarse pipeline a manager thinks in: NEW -> PROCESSING ->
* COMPLETED. This is deliberately coarser than `TAB_GROUP_ITEM_STATUSES`
* (which is the desk's eight-tab workflow view) — a manager opening the
* page on a phone wants "where is everything right now", not which of
* the eight workflow tabs a line happens to sit in.
*
* `COMPLETED` is time-windowed (see `completedTabFilter` in the server
* orders service); the other two are not.
*/
export type PipelineGroup = "new" | "processing" | "completed";
export const PIPELINE_GROUP_ORDER: PipelineGroup[] = [
"new",
"processing",
"completed",
];
export const PIPELINE_GROUP_ITEM_STATUSES: Record<PipelineGroup, ItemStatus[]> = {
// Just raised, nobody has touched it yet.
new: ["pending", "approval_needed", "approval_rejected"],
// Everything in flight: PO'd, waiting for collection, with the
// driver, awaiting an invoice.
processing: [
"processed", "po_generated",
"collection_pool", "assigned", "ready_delivery",
"collected", "stock_verified", "picking",
"ready_invoice", "stock_booked", "with_driver",
],
// Settled lines. Only the ones that settled inside the window show.
completed: SETTLED_STATUSES,
};
export const PIPELINE_GROUP_VISUAL: Record<
PipelineGroup,
{ hex: string; label: string }
> = {
new: { hex: "#2563eb", label: "New" },
processing: { hex: "#b45309", label: "Processing" },
completed: { hex: "#064e3b", label: "Completed" },
};
/**
* Map an item status to its pipeline bucket.
*
* Returns `null` for `cancelled` and `removed` — a cancelled order is
* not part of the pipeline a manager is tracking, and it lives on
* /orders/archive. Every OTHER member of the `ItemStatus` union must
* be listed in `PIPELINE_GROUP_ITEM_STATUSES`; an unmapped status
* throws rather than silently vanishing from the manager's view.
*/
export function itemStatusToPipelineGroup(s: ItemStatus): PipelineGroup | null {
for (const group of PIPELINE_GROUP_ORDER) {
if (PIPELINE_GROUP_ITEM_STATUSES[group].includes(s)) return group;
}
if (s === "cancelled" || s === "removed") return null;
throw new Error(
`itemStatusToPipelineGroup: status "${s}" is not mapped to any pipeline group. ` +
`Add it to PIPELINE_GROUP_ITEM_STATUSES in src/lib/item-status-ui.ts.`,
);
}
/* ============================================================================
* Shared utilities
* ========================================================================== */
/** Add an alpha channel to a hex. `withAlpha("#2563eb", 12)` → "#2563eb1f". */
export function withAlpha(hex: string, alphaPercent: number): string {
const a = Math.round((alphaPercent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${hex}${a}`;
}