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:
parent
6c07a91693
commit
5c46dfed88
5 changed files with 288 additions and 93 deletions
|
|
@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, " ");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue