WFDASH-WEBAPP/src/lib/quote-status.ts
Dave 3de1d622b8 feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.

Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.

Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 16:51:55 +02:00

203 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string> = {
// 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<string, string> = {
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<QuoteStatus>([
"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<Record<QuoteStatus, string>> = {
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;
}