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.
381 lines
16 KiB
TypeScript
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}`;
|
|
}
|