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.
203 lines
9.2 KiB
TypeScript
203 lines
9.2 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|