/** * Theme-driven status palettes. Every entry uses semantic tokens * (--info / --warn / --success / --destructive) so the colour * palette follows the active theme — switching from crisp→nvidia→neon * changes which exact hue "approved" renders as, but the *meaning* * (success = green, warning = amber, etc.) stays consistent. * * Keeping the semantic names intentional: they're named after what * the status MEANS to the operator, not after a Tailwind palette * name. That way the suite works across every theme in the picker. */ import type { QuoteStatus } from "@/lib/types"; const quoteStatusClasses: Record = { // In-flight / awaiting further action — info (cool blue) new: "border-info/30 bg-info/10 text-info", claimed: "border-info/30 bg-info/10 text-info", processed: "border-info/30 bg-info/10 text-info", emailed: "border-info/30 bg-info/10 text-info", sending: "border-info/40 bg-info/15 text-info", template: "border-info/30 bg-info/10 text-info", // Soft warning — needs operator attention soon // // `text-warn-foreground` is a dark amber (~oklch 0.13–0.22 // across themes) designed to sit on top of a LIGHT amber tint // (bg-warn/15). In light mode the badge background is a // pleasant pale wash and the dark text pops. // // In dark mode the same `bg-warn/15` blends into the dark card // and produces a near-black amber tint, which the dark // foreground then sits on invisibly — classic dark-on-dark. // // `dark:text-warn` swaps to the full bright warn hue // (~oklch 0.78 in dark themes) so the text is luminous amber // on the muted dark tint. Light-mode behaviour is unchanged. approval_pending: "border-warn/40 bg-warn/15 text-warn-foreground dark:text-warn", // Partial-approval variants — added 2026-07-24. Same warning palette // as approval_pending (operator + rep both treat these as "needs // eyes") but a different border weight so the rep can tell at a // glance that the submission is incomplete. Same dark-mode // treatment as approval_pending — see note above. partial_approval_pending: "border-warn/30 bg-warn/10 text-warn-foreground dark:text-warn", follow_up: "border-warn/40 bg-warn/15 text-warn-foreground dark:text-warn", // Terminal-positive approved: "border-success/30 bg-success/10 text-success", complete: "border-success/30 bg-success/10 text-success", // Partial-approved (terminal for this batch, but a continuation quote // exists with the remaining items). Slightly muted success colour so // the operator can tell the original isn't "fully done" — the // continuation is the live quote. partial_approved: "border-success/30 bg-success/5 text-success/80", // Terminal-negative rejected: "border-destructive/30 bg-destructive/10 text-destructive", email_failed: "border-destructive/30 bg-destructive/10 text-destructive", mail_failed: "border-destructive/40 bg-destructive/15 text-destructive", unmatched_bounce: "border-destructive/30 bg-destructive/10 text-destructive", problem: "border-destructive/30 bg-destructive/10 text-destructive", // Neutral terminal cancelled: "border-muted bg-muted text-muted-foreground", }; export function quoteStatusClassName(status: string) { return quoteStatusClasses[status] || "border-muted bg-muted text-muted-foreground"; } /** * Human-friendly status label. Most statuses just replace underscores * with spaces ("approval_pending" → "approval pending"), but a handful * of legacy PB status strings render better with bespoke labels so the * operator reads them as workflow states rather than raw enum values: * * emailed → "Awaiting reply" * (the email was sent to the customer; we're waiting for them to come back. * The legacy enum `emailed` doesn't read like a state on its own.) * sending → "Sending…" * (transient — outbound send in flight, n8n is processing.) * follow_up → "Follow up" * (just title-case it for nicer rendering.) */ const STATUS_LABELS: Record = { emailed: "Awaiting reply", sending: "Sending…", follow_up: "Follow up", approval_pending: "Pending approval", // Partial-approval labels — distinguish "full quote awaiting eyes" // from "5 of 15 awaiting eyes". The "partial" prefix keeps the // signal clear in lists, filters, and badge tooltips. partial_approval_pending: "Partial — pending approval", partial_approved: "Partial — sent", unmatched_bounce: "Unmatched bounce", email_failed: "Email failed", mail_failed: "Mail failed", // "delivery_note" is the status the office gives a quote that // prices a delivery note. The operator claims it, fills in // prices, and sends it without going through the regular // approval queue — the items are already with the customer, so // approval is implicit. See the v2.1 sendQuoteEmail change for // the matching skip-approval logic. delivery_note: "Delivery note", }; export function formatQuoteStatus(status: string) { return STATUS_LABELS[status] || status.replace(/_/g, " "); } /** * Quote statuses that can be re-submitted through the approval queue * via POST /api/v1/quotes/[id]/submit. Lives in the shared status * helper so the client UI (banner gate) and the server * (`submitQuote` in `quotes-service.ts`) draw from the same source * of truth — a new status added here surfaces in both places without * a second edit. * * Background: before this set existed (pre-2026-09-07), `submitQuote` * only accepted `new`. Once a quote had been emailed and the customer * came back asking for changes, the operator's only path was to * re-quote from scratch, which threw away the original quote_number, * email tracking, and the customer's audit trail. Re-submitting on * the same quote preserves every one of those. * * Includes: * - `emailed` → customer came back with changes * - `complete` → customer came back after closure * - `partial_approved` → operator wants to re-open a partial * for a fresh full-approval cycle * - `mail_failed` → operator fixed the underlying issue * and wants to retry through approval * - `email_failed` → ditto, for the legacy alias * - `approved` → approved but not yet sent; operator * wants a re-review before sending * - `follow_up` → operator followed up, now wants a * fresh approval round * - `problem` → operator flagged a problem, fixed it, * now wants re-approval * - `rejected` → legacy terminal state — still reachable * for quotes that pre-date the new `new` * bounce-back flow * * Does NOT include (left to throw `invalid_status`): * - `cancelled` → terminal — operator took it out of the * queue intentionally * - `claimed` → operator hasn't priced yet; they should * "Process Items" first * - `sending` → SMTP in flight; clobbering would orphan * the outbound send * - `template` → not a real quote; templates don't go to * the approval queue */ export const RESUBMITTABLE_STATUSES = new Set([ "emailed", "complete", "partial_approved", "mail_failed", "email_failed", "approved", "follow_up", "problem", "rejected", ]); export function isResubmittableStatus(status: QuoteStatus | string | undefined | null): boolean { if (!status) return false; return RESUBMITTABLE_STATUSES.has(status as QuoteStatus); } /** * Per-status one-liner for the info variant of the Resubmit banner. * Keeps the messaging in one place so the page doesn't grow a forest * of conditionals. Statuses that have no entry fall through to the * generic `defaultMessage`. The destructive variant (rejection case) * owns its own copy in the page component — it carries the * rejection-reason block which these short messages don't. */ const RESUBMIT_MESSAGES: Partial> = { emailed: "Customer came back with changes — resubmit to send the revised quote through the approval queue.", complete: "Quote closed — resubmit to reopen for a fresh approval cycle.", follow_up: "Follow-up round complete — resubmit for a fresh approval round.", problem: "Problem resolved — resubmit for a fresh approval cycle.", mail_failed: "Previous email failed — fix the underlying issue, then resubmit.", email_failed: "Previous email failed — fix the underlying issue, then resubmit.", approved: "Approved but not yet sent — resubmit to send back through approval before sending.", partial_approved: "Partial — resubmit for a fresh full-approval cycle.", rejected: "Rejected — fix and resubmit for a fresh approval cycle.", }; const RESUBMIT_DEFAULT_MESSAGE = "Resubmit for a fresh approval cycle."; export function resubmitStatusMessage(status: QuoteStatus | string | undefined | null): string { if (!status) return RESUBMIT_DEFAULT_MESSAGE; return RESUBMIT_MESSAGES[status as QuoteStatus] ?? RESUBMIT_DEFAULT_MESSAGE; }