Add full PIN-protected TV dashboard stack including auth, redirects, map view, APIs, and server services. Also include these critical refinements: - Overhaul PO number generation to use per-company monthly sequences - Rework quote rejection to bounce quotes back to operator queues instead of marking them terminal - Add post_processing order tab group for clearer workflow visibility - Fix collection pool tooltip positioning, overflow clipping, and default collapsed state - Refactor customer list API, clean up unused form props, and fix item intel popover target ID handling - Add comprehensive tests for new components and update existing test expectations
229 lines
10 KiB
TypeScript
229 lines
10 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 a delivery
|
|
* note has been generated they're sitting on "post_processing" until
|
|
* the operator finalises them; once queued for collection they slide
|
|
* to "collection pool"; back at the office they move to "collected";
|
|
* delivered is terminal; cancelled is its own tab so the operator
|
|
* can audit it without it drowning the active tabs.
|
|
*/
|
|
export type TabGroup =
|
|
| "new"
|
|
| "processing"
|
|
| "post_processing"
|
|
| "collection_pool"
|
|
| "collected"
|
|
| "delivered"
|
|
| "cancelled";
|
|
|
|
export const TAB_GROUP_ORDER: TabGroup[] = [
|
|
"new",
|
|
"processing",
|
|
"post_processing",
|
|
"collection_pool",
|
|
"collected",
|
|
"delivered",
|
|
"cancelled",
|
|
];
|
|
|
|
export const TAB_GROUP_ITEM_STATUSES: Record<TabGroup, ItemStatus[]> = {
|
|
new: ["pending", "approval_needed", "approval_rejected"],
|
|
processing: ["processed", "po_generated"],
|
|
// Items where a delivery note has been generated but the operator
|
|
// hasn't yet run them through the rest of the post-collection
|
|
// flow. Until 2026-08-13 these sat on "processing", but that tab
|
|
// was getting crowded with rows that are *past* the PO stage —
|
|
// the DN has been issued, the supplier has confirmed, the buyer
|
|
// just needs to actually finish invoicing/collecting. Splitting
|
|
// them out into "post_processing" makes the operator's Triage
|
|
// view (what needs me?) honest: processing = needs a PO,
|
|
// post_processing = needs collection/invoice/admin to be wrapped.
|
|
post_processing: ["delivery_note"],
|
|
// 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, and the
|
|
// downstream stock-booked / checked_in / completed steps all live
|
|
// here. 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", "checked_in",
|
|
"completed"],
|
|
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" },
|
|
// Distinct enough from processing (amber) and collection_pool
|
|
// (purple) to read at a glance: the operator should see a row
|
|
// here and immediately think "DN issued, not yet finalised".
|
|
// The dark-orange hex mirrors the per-item `delivery_note` pill
|
|
// (`#7c2d12` in `ITEM_STATUS_VISUAL`) so the two stay in the same
|
|
// warm family.
|
|
post_processing: { hex: "#7c2d12", label: "Post-Processing" },
|
|
collection_pool: { hex: "#7c3aed", label: "Collection Pool" },
|
|
collected: { hex: "#0f766e", label: "Collected" },
|
|
delivered: { hex: "#047857", label: "Delivered" },
|
|
cancelled: { hex: "#e11d48", label: "Cancelled" },
|
|
};
|
|
|
|
/* ============================================================================
|
|
* 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}`;
|
|
}
|