From 5c46dfed88da717d9e8dd9cc118f9c6eda40f68b Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 29 Jun 2026 22:15:32 +0200 Subject: [PATCH] refactor(quotes): collapse 10 workflow tabs into 8, add follow-up + replied chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(app)/quotes/[id]/page.tsx | 20 ++-- src/app/(app)/quotes/page.tsx | 164 ++++++++++++++++++++++------- src/lib/pb-helpers.ts | 152 ++++++++++++++++++-------- src/lib/quote-status.test.ts | 19 +++- src/lib/quote-status.ts | 26 ++++- 5 files changed, 288 insertions(+), 93 deletions(-) diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index ccc49cf..b5c552c 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -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 = { 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", }; /** diff --git a/src/app/(app)/quotes/page.tsx b/src/app/(app)/quotes/page.tsx index 133ce0d..a6076fe 100644 --- a/src/app/(app)/quotes/page.tsx +++ b/src/app/(app)/quotes/page.tsx @@ -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 = { - 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 = { */ 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 = { + 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 = { 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>(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() {
{item.quote_number} + {isFollowUp(item) && } + {hasCustomerReply(item) && } {isAged(item) && }
), @@ -535,8 +552,10 @@ export default function QuotesPage() {
-
+

{item.quote_number}

+ {isFollowUp(item) && } + {hasCustomerReply(item) && } {isAged(item) && }

@@ -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 ( + + + ); +} + +/** + * 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 ( + + + ); +} + // ───────────────────────────────────────────────────────────────────────────── // Expanded-row content // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index 4f5f7ae..faaf10e 100644 --- a/src/lib/pb-helpers.ts +++ b/src/lib/pb-helpers.ts @@ -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` + * 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 = { + 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(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(1, 1, { filter: templateFilter })); + return requests; + }; + try { - const [all, ...perStatus] = await Promise.all([ - pb.collection("quotes").getList(1, 1, { filter: notTemplate }), - ...statuses.map((status) => - pb.collection("quotes").getList(1, 1, { - filter: `status = "${status}" && ${notTemplate}`, - }) - ), - ]); - const counts: Record = { all: all.totalItems }; - statuses.forEach((status, i) => { - counts[status] = perStatus[i].totalItems; + const perStatus = await Promise.all(buildRequests(true)); + const counts: Record = {}; + 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(1, 1), - ...statuses.map((status) => - pb.collection("quotes").getList(1, 1, { filter: `status = "${status}"` }) - ), - ]); - const counts: Record = { all: all.totalItems }; - statuses.forEach((status, i) => { - counts[status] = perStatus[i].totalItems; + const perStatus = await Promise.all(buildRequests(false)); + const counts: Record = {}; + 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; } } diff --git a/src/lib/quote-status.test.ts b/src/lib/quote-status.test.ts index 02b16e6..ea20295 100644 --- a/src/lib/quote-status.test.ts +++ b/src/lib/quote-status.test.ts @@ -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"); }); }); diff --git a/src/lib/quote-status.ts b/src/lib/quote-status.ts index 6edfea9..9495bc5 100644 --- a/src/lib/quote-status.ts +++ b/src/lib/quote-status.ts @@ -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 = { + 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, " "); }