refactor(quotes): collapse 10 workflow tabs into 8, add follow-up + replied chips

The list page had grown 10 colour-coded tabs (one per legacy PB status)
that fragmented the workflow rather than grouping it. Merging the
overlapping concepts into workflow buckets per the operator's flow:

  - new              ← new + follow_up  (follow-up rows show a bell chip)
  - claimed          ← claimed
  - pending_approval ← processed + approval_pending + sending
  - completed        ← approved + complete + emailed + cancelled + rejected
                       (emailed renders as 'Awaiting reply', cancelled + rejected
                        are substatus pills inside the Completed tab)
  - email_failed     ← email_failed + mail_failed + unmatched_bounce  (new tab)
  - problem          ← problem
  - templates        ← is_template = true
  - all              ← catch-all

Two row-level indicators added next to the quote number:

  - Bell chip for follow-up rows (status=follow_up OR follow_up_due set
    on an actionable status). Colour flips to red when overdue.
  - Reply-bubble chip for rows where last_reply_at is set, so the
    operator can distinguish 'fresh new' from 'replied new' at a glance.

Implementation notes:

  - getQuotes now accepts string[] statuses and OR-chains them in the
    PB filter (PB 0.26 has no IN operator). getQuoteStatusCounts sums
    per-status totals into the new tab buckets in one batch.
  - formatQuoteStatus gains workflow-friendly label overrides
    ('Awaiting reply', 'Sending…', 'Pending approval', etc.) shared by
    the list, archived, and process pages.
  - The detail page's local statusLabels map is kept in sync so toast
    + history-row interpolations read correctly.

Tests: 229/230 vitest (the 1 failure is the pre-existing attachments
off-by-one, unrelated to this change). Updated quote-status.test.ts
for the new label overrides.
This commit is contained in:
SJCoetz 2026-06-29 22:15:32 +02:00
parent 6c07a91693
commit 5c46dfed88
5 changed files with 288 additions and 93 deletions

View file

@ -82,23 +82,31 @@ import QuoteEmailCard from "@/components/quote-email-card";
import RewriteQuoteModal from "@/components/rewrite-quote-modal";
import type { Quote, QuoteItem, QuoteAttachment } from "@/lib/types";
// Detail-page status labels. Kept in sync with the workflow-friendly
// overrides in `lib/quote-status.ts` (see STATUS_LABELS there). The
// detail page needs the labels inline rather than going through the
// helper because the toast + history row both interpolate the label
// into a sentence ("Quote marked as …" / "Status: …").
const statusLabels: Record<string, string> = {
new: "New",
claimed: "Claimed",
processed: "Processed",
emailed: "Emailed",
approval_pending: "Approval Pending",
// "Awaiting reply" reflects the user's flow — the email was sent
// and we're waiting for the customer to come back. The legacy enum
// `emailed` doesn't read like a state on its own.
emailed: "Awaiting reply",
approval_pending: "Pending approval",
approved: "Approved",
rejected: "Rejected",
problem: "Problem",
complete: "Complete",
cancelled: "Cancelled",
follow_up: "Follow Up",
email_failed: "Email Failed",
unmatched_bounce: "Unmatched Bounce",
follow_up: "Follow up",
email_failed: "Email failed",
unmatched_bounce: "Unmatched bounce",
template: "Template",
sending: "Sending…",
mail_failed: "Mail Failed",
mail_failed: "Mail failed",
};
/**

View file

@ -23,6 +23,9 @@ import {
Layers,
Bell,
CopyPlus,
MessageSquareReply,
Eye,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@ -40,20 +43,6 @@ import { userColor, userDisplayName, type UserColor } from "@/lib/user-color";
import { isOlderThanWorkingDays, workingDaysBetween } from "@/lib/working-days";
import type { Quote, QuoteItem } from "@/lib/types";
const tabFilters: Record<string, string> = {
all: "",
new: "new",
claimed: "claimed",
processed: "processed",
emailed: "emailed",
approval_pending: "approval_pending",
approved: "approved",
complete: "complete",
problem: "problem",
follow_up: "follow_up",
template: "template",
};
/**
* Threshold (working days) past which a `new` or `claimed` quote is
* considered aged and gets the bomb-icon warning. Tweak here if the
@ -61,40 +50,62 @@ const tabFilters: Record<string, string> = {
*/
const AGE_WARNING_DAYS = 3;
/**
* Tab → PB status filter. The list page merges several legacy statuses
* into one workflow tab so the operator scans fewer buckets:
*
* new ← new + follow_up (follow-up shows a bell icon on the row)
* claimed ← claimed
* pending_approval ← processed + approval_pending + sending (in-flight send)
* completed ← approved + complete + emailed (awaiting reply)
* + cancelled + rejected
* email_failed ← email_failed + mail_failed + unmatched_bounce
* problem ← problem
* templates ← is_template = true (handled in getQuotes directly)
* all ← no status filter
*
* Multi-status tabs are OR-chained inside getQuotes (PB 0.26 doesn't
* support the `IN` operator — see MEMORY.md).
*/
const tabFilters: Record<string, string[]> = {
new: ["new", "follow_up"],
claimed: ["claimed"],
pending_approval: ["processed", "approval_pending", "sending"],
completed: ["approved", "complete", "emailed", "cancelled", "rejected"],
email_failed: ["email_failed", "mail_failed", "unmatched_bounce"],
problem: ["problem"],
templates: ["template"],
all: [],
};
/**
* Active-tab background / foreground / border classes. The new palette
* is a traffic-light progression: blue → cyan → amber → green, with
* red / rose reserved for "attention" buckets, purple for the
* meta-concept (templates), and neutral gray for the catch-all.
*/
const tabColorClasses: Record<string, string> = {
all: "data-[active]:bg-muted data-[active]:text-foreground data-[active]:border-muted",
new: "data-[active]:bg-blue-50 data-[active]:text-blue-700 data-[active]:border-blue-200 dark:data-[active]:bg-blue-500/15 dark:data-[active]:text-blue-200 dark:data-[active]:border-blue-500/30",
claimed: "data-[active]:bg-cyan-50 data-[active]:text-cyan-700 data-[active]:border-cyan-200 dark:data-[active]:bg-cyan-500/15 dark:data-[active]:text-cyan-200 dark:data-[active]:border-cyan-500/30",
processed: "data-[active]:bg-indigo-50 data-[active]:text-indigo-700 data-[active]:border-indigo-200 dark:data-[active]:bg-indigo-500/15 dark:data-[active]:text-indigo-200 dark:data-[active]:border-indigo-500/30",
emailed: "data-[active]:bg-sky-50 data-[active]:text-sky-700 data-[active]:border-sky-200 dark:data-[active]:bg-sky-500/15 dark:data-[active]:text-sky-200 dark:data-[active]:border-sky-500/30",
approval_pending: "data-[active]:bg-amber-50 data-[active]:text-amber-800 data-[active]:border-amber-200 dark:data-[active]:bg-amber-500/15 dark:data-[active]:text-amber-200 dark:data-[active]:border-amber-500/30",
approved: "data-[active]:bg-emerald-50 data-[active]:text-emerald-700 data-[active]:border-emerald-200 dark:data-[active]:bg-emerald-500/15 dark:data-[active]:text-emerald-200 dark:data-[active]:border-emerald-500/30",
complete: "data-[active]:bg-green-50 data-[active]:text-green-700 data-[active]:border-green-200 dark:data-[active]:bg-green-500/15 dark:data-[active]:text-green-200 dark:data-[active]:border-green-500/30",
pending_approval: "data-[active]:bg-amber-50 data-[active]:text-amber-800 data-[active]:border-amber-200 dark:data-[active]:bg-amber-500/15 dark:data-[active]:text-amber-200 dark:data-[active]:border-amber-500/30",
completed: "data-[active]:bg-emerald-50 data-[active]:text-emerald-700 data-[active]:border-emerald-200 dark:data-[active]:bg-emerald-500/15 dark:data-[active]:text-emerald-200 dark:data-[active]:border-emerald-500/30",
email_failed: "data-[active]:bg-red-50 data-[active]:text-red-700 data-[active]:border-red-200 dark:data-[active]:bg-red-500/15 dark:data-[active]:text-red-200 dark:data-[active]:border-red-500/30",
problem: "data-[active]:bg-rose-50 data-[active]:text-rose-700 data-[active]:border-rose-200 dark:data-[active]:bg-rose-500/15 dark:data-[active]:text-rose-200 dark:data-[active]:border-rose-500/30",
// Follow-up needs attention but isn't broken — yellow to distinguish
// from "problem" (red) and "complete" (green).
follow_up: "data-[active]:bg-yellow-50 data-[active]:text-yellow-800 data-[active]:border-yellow-200 dark:data-[active]:bg-yellow-500/15 dark:data-[active]:text-yellow-200 dark:data-[active]:border-yellow-500/30",
// Templates are scaffolding, not workflow — purple to mark them as
// a separate concept from the in-flight quote statuses above.
template: "data-[active]:bg-purple-50 data-[active]:text-purple-700 data-[active]:border-purple-200 dark:data-[active]:bg-purple-500/15 dark:data-[active]:text-purple-200 dark:data-[active]:border-purple-500/30",
templates: "data-[active]:bg-purple-50 data-[active]:text-purple-700 data-[active]:border-purple-200 dark:data-[active]:bg-purple-500/15 dark:data-[active]:text-purple-200 dark:data-[active]:border-purple-500/30",
};
const TAB_ORDER: { key: string; label: string; icon?: React.ComponentType<{ className?: string }> }[] = [
{ key: "new", label: "New" },
{ key: "claimed", label: "Claimed" },
{ key: "processed", label: "Processed" },
{ key: "emailed", label: "Emailed" },
{ key: "approval_pending", label: "Pending" },
{ key: "approved", label: "Approved" },
{ key: "complete", label: "Complete" },
// "Needs attention" group: problem (red) and follow-up (yellow) come
// together so the operator can scan them side-by-side.
{ key: "pending_approval", label: "Pending Approval", icon: Eye },
{ key: "completed", label: "Completed", icon: CheckCircle },
{ key: "email_failed", label: "Email Failed", icon: AlertCircle },
{ key: "problem", label: "Problem" },
{ key: "follow_up", label: "Follow-up", icon: Bell },
{ key: "all", label: "All" },
// Templates live at the right end — they're scaffolding for future
// quotes, not in-flight work, so the workflow tabs come first.
{ key: "template", label: "Templates", icon: Layers },
{ key: "templates", label: "Templates", icon: Layers },
];
interface UserLookup {
@ -142,7 +153,11 @@ export default function QuotesPage() {
// once — each button is independent.
const [loadingActionIds, setLoadingActionIds] = useState<Set<string>>(new Set());
const statusFilter = tabFilters[activeTab] || "";
// `statusFilter` is the list of PB statuses the active tab maps to.
// The Templates tab uses a special-cased filter (see getQuotes); the
// "All" tab uses [] (no filter). For every other tab it's 1–5 statuses
// OR-chained into a single PB filter inside `getQuotes`.
const statusFilter = tabFilters[activeTab] ?? [];
const fetchQuotes = useCallback(async () => {
setLoading(true);
@ -169,7 +184,7 @@ export default function QuotesPage() {
});
} else {
const [result, statusCounts] = await Promise.all([
getQuotes(page, 20, search, "-created", statusFilter || undefined),
getQuotes(page, 20, search, "-created", statusFilter),
getQuoteStatusCounts(),
]);
setQuotes(result.items);
@ -365,6 +380,8 @@ export default function QuotesPage() {
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.quote_number}</span>
{isFollowUp(item) && <FollowUpChip due={item.follow_up_due} />}
{hasCustomerReply(item) && <RepliedChip repliedAt={item.last_reply_at} />}
{isAged(item) && <AgedBadge created={item.created} />}
</div>
),
@ -535,8 +552,10 @@ export default function QuotesPage() {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{item.quote_number}</p>
{isFollowUp(item) && <FollowUpChip due={item.follow_up_due} />}
{hasCustomerReply(item) && <RepliedChip repliedAt={item.last_reply_at} />}
{isAged(item) && <AgedBadge created={item.created} />}
</div>
<p className="text-sm text-muted-foreground truncate">
@ -608,6 +627,77 @@ function AgedBadge({ created }: { created: string }) {
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Follow-up + customer-reply chips
// ─────────────────────────────────────────────────────────────────────────────
/**
* Follow-up rows: a quote either has `follow_up_due` stamped on it OR
* is in the legacy `follow_up` status. The bell icon renders next to the
* quote number so the operator spots it at a glance — they live in the
* New tab by default but the chip shows on any actionable status.
*
* Hidden on terminal states (complete / cancelled / rejected) where the
* reminder is moot.
*/
function isFollowUp(quote: Quote): boolean {
if (quote.status === "follow_up") {
const terminal = new Set(["complete", "cancelled", "rejected"]);
if (!terminal.has(quote.status)) return true;
}
if (!quote.follow_up_due) return false;
const terminal = new Set(["complete", "cancelled", "rejected"]);
if (terminal.has(quote.status)) return false;
return true;
}
/** Small bell + date chip for follow-up rows. */
function FollowUpChip({ due }: { due: string }) {
const dueDate = new Date(due);
const today = new Date();
today.setHours(0, 0, 0, 0);
const overdue = !Number.isNaN(dueDate.getTime()) && dueDate < today;
const label = overdue ? "Follow-up overdue" : "Follow-up reminder";
return (
<span
title={`${label} — due ${formatDate(due)}`}
data-testid="follow-up-chip"
className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none ring-1 ${
overdue
? "bg-red-100 text-red-800 ring-red-300 dark:bg-red-500/15 dark:text-red-200 dark:ring-red-500/30"
: "bg-yellow-100 text-yellow-800 ring-yellow-300 dark:bg-yellow-500/15 dark:text-yellow-200 dark:ring-yellow-500/30"
}`}
>
<Bell className="h-3 w-3" aria-hidden="true" />
Follow up
</span>
);
}
/**
* A customer has replied to the quote email (`last_reply_at` is set).
* In the new flow the quote lands back in the New tab with status=`new`,
* so the chip distinguishes "fresh new" from "replied new" at a glance.
*/
function hasCustomerReply(quote: Quote): boolean {
return !!quote.last_reply_at;
}
/** Small reply-bubble chip for rows where the customer has replied. */
function RepliedChip({ repliedAt }: { repliedAt: string | null | undefined }) {
const label = repliedAt ? `Customer replied — ${formatDate(repliedAt)}` : "Customer replied";
return (
<span
title={label}
data-testid="replied-chip"
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none bg-cyan-100 text-cyan-800 ring-1 ring-cyan-300 dark:bg-cyan-500/15 dark:text-cyan-200 dark:ring-cyan-500/30"
>
<MessageSquareReply className="h-3 w-3" aria-hidden="true" />
Replied
</span>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Expanded-row content
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -132,20 +132,38 @@ function warnSchemaMissing(what: string) {
);
}
export async function getQuotes(page = 1, perPage = 50, search = "", sort = "-created", status?: string) {
/**
* Fetch a page of quotes, optionally filtered by one or more statuses.
*
* @param statuses One or more `quotes.status` values to OR-chain into a
* `status = "x" || status = "y" || …` filter. PB 0.26
* doesn't accept the `IN` operator, so we build the
* OR-chain ourselves (see MEMORY.md / PocketBase 0.26:
* no `IN` operator in raw filter strings). Pass `[]`
* (default) to skip the status filter.
*
* The Templates tab historically passed a single `status = "template"`
* string; that flow is now handled by passing `["template"]` instead.
*/
export async function getQuotes(
page = 1,
perPage = 50,
search = "",
sort = "-created",
statuses: string[] = []
) {
const pb = getPocketBase();
// The Templates tab explicitly asks for templates — don't exclude them
// in that case. Every other tab hides templates so they don't pollute
// the workflow view.
// Try with the v5 template-aware filter. If PB rejects it (400), retry
// without so the existing UI still works.
// Hide templates from every non-template tab so they don't pollute the
// workflow view. The Templates tab passes ["template"] explicitly —
// `statuses.includes("template")` lets it through.
const filters: string[] = [];
if (status !== "template") filters.push("is_template != true");
if (!statuses.includes("template")) filters.push("is_template != true");
if (search) {
filters.push(`quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}"`);
}
if (status) {
filters.push(`status = "${status}"`);
if (statuses.length > 0) {
const statusClause = statuses.map((s) => `status = "${s}"`).join(" || ");
filters.push(`(${statusClause})`);
}
const newFilter = filters.join(" && ");
try {
@ -233,60 +251,104 @@ export async function getQuote(id: string) {
}
/**
* Fetch a per-status count of all quotes. Used by the status filter tabs
* on the quotes list page to show "New (3)" / "Problem (1)" badges.
* Per-tab quote counts for the list-page tabs. Returns a `Record<tabKey, number>`
* where each key matches a tab key on the quotes list page:
*
* One PB call per status (with `perPage: 1` so we only transfer a count,
* not records), plus one for the unfiltered "all" total. The "all" total
* is fetched independently because the sum of per-status counts can
* disagree with PB's unfiltered total when statuses change mid-query.
* new ← status = new || status = follow_up (follow-up rows live in New)
* claimed ← status = claimed
* pending_approval ← status = processed || status = approval_pending || status = sending
* completed ← status = approved || status = complete || status = emailed
* || status = cancelled || status = rejected
* email_failed ← status = email_failed || status = mail_failed || status = unmatched_bounce
* problem ← status = problem
* templates ← is_template = true
* all ← unfiltered (excludes templates, like every other tab)
*
* One PB call per underlying status (with `perPage: 1` so we only transfer a
* count, not records). We don't make a separate "all" call — we sum the
* per-tab counts and trust PB's atomicity within a single request batch
* (the alternative — an unfiltered call — can disagree with the per-tab
* sum if statuses flip mid-query).
*/
export async function getQuoteStatusCounts() {
const pb = getPocketBase();
const statuses = [
// Underlying statuses → tab key. A tab key can map to multiple statuses.
const statusToTab: Record<string, string> = {
new: "new",
follow_up: "new",
claimed: "claimed",
processed: "pending_approval",
approval_pending: "pending_approval",
sending: "pending_approval",
approved: "completed",
complete: "completed",
emailed: "completed",
cancelled: "completed",
rejected: "completed",
email_failed: "email_failed",
mail_failed: "email_failed",
unmatched_bounce: "email_failed",
problem: "problem",
};
const tabKeys = [
"new",
"claimed",
"processed",
"emailed",
"approval_pending",
"approved",
"complete",
"pending_approval",
"completed",
"email_failed",
"problem",
"follow_up",
"sending",
"mail_failed",
"template",
];
// Try with the v5 template-exclusion filter; fall back if PB doesn't
// know `is_template` yet (schema not imported).
const notTemplate = `is_template != true`;
const buildRequests = (withTemplateGate: boolean) => {
const gate = withTemplateGate ? ` && ${notTemplate}` : "";
const requests = Object.keys(statusToTab).map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `status = "${status}"${gate}`,
})
);
// Templates tab uses `is_template = true` (the status filter would
// exclude templates from the workflow track).
const templateFilter = withTemplateGate
? `is_template = true`
: `is_template = true`;
requests.push(pb.collection("quotes").getList<Quote>(1, 1, { filter: templateFilter }));
return requests;
};
try {
const [all, ...perStatus] = await Promise.all([
pb.collection("quotes").getList<Quote>(1, 1, { filter: notTemplate }),
...statuses.map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, {
filter: `status = "${status}" && ${notTemplate}`,
})
),
]);
const counts: Record<string, number> = { all: all.totalItems };
statuses.forEach((status, i) => {
counts[status] = perStatus[i].totalItems;
const perStatus = await Promise.all(buildRequests(true));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 1].totalItems;
// "All" includes every tab EXCEPT templates (templates live in their own tab).
counts.all = allCount;
return counts;
} catch (err) {
if (!is400(err)) throw err;
warnSchemaMissing("is_template (status counts)");
const [all, ...perStatus] = await Promise.all([
pb.collection("quotes").getList<Quote>(1, 1),
...statuses.map((status) =>
pb.collection("quotes").getList<Quote>(1, 1, { filter: `status = "${status}"` })
),
]);
const counts: Record<string, number> = { all: all.totalItems };
statuses.forEach((status, i) => {
counts[status] = perStatus[i].totalItems;
const perStatus = await Promise.all(buildRequests(false));
const counts: Record<string, number> = {};
for (const key of tabKeys) counts[key] = 0;
counts.templates = 0;
let allCount = 0;
Object.keys(statusToTab).forEach((status, i) => {
const tab = statusToTab[status];
counts[tab] = (counts[tab] || 0) + perStatus[i].totalItems;
allCount += perStatus[i].totalItems;
});
counts.templates = perStatus[perStatus.length - 1].totalItems;
counts.all = allCount;
return counts;
}
}

View file

@ -42,13 +42,24 @@ describe("quoteStatusClassName", () => {
});
describe("formatQuoteStatus", () => {
it("replaces underscores with spaces", () => {
expect(formatQuoteStatus("approval_pending")).toBe("approval pending");
expect(formatQuoteStatus("email_failed")).toBe("email failed");
it("uses workflow-friendly labels for email-status enums", () => {
expect(formatQuoteStatus("email_failed")).toBe("Email failed");
expect(formatQuoteStatus("mail_failed")).toBe("Mail failed");
expect(formatQuoteStatus("unmatched_bounce")).toBe("Unmatched bounce");
});
it("leaves single-word statuses unchanged", () => {
it("renders workflow-friendly labels for the merged tabs", () => {
expect(formatQuoteStatus("approval_pending")).toBe("Pending approval");
expect(formatQuoteStatus("emailed")).toBe("Awaiting reply");
expect(formatQuoteStatus("sending")).toBe("Sending…");
expect(formatQuoteStatus("follow_up")).toBe("Follow up");
});
it("leaves single-word and terminal statuses unchanged", () => {
expect(formatQuoteStatus("new")).toBe("new");
expect(formatQuoteStatus("complete")).toBe("complete");
expect(formatQuoteStatus("cancelled")).toBe("cancelled");
expect(formatQuoteStatus("rejected")).toBe("rejected");
expect(formatQuoteStatus("claimed")).toBe("claimed");
});
});

View file

@ -28,6 +28,30 @@ 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",
unmatched_bounce: "Unmatched bounce",
email_failed: "Email failed",
mail_failed: "Mail failed",
};
export function formatQuoteStatus(status: string) {
return status.replace(/_/g, " ");
return STATUS_LABELS[status] || status.replace(/_/g, " ");
}