feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements

This commit is contained in:
2026-03-06 23:49:27 -06:00
parent 762edd8eb7
commit b735981b6b
17 changed files with 4222 additions and 129 deletions
+68 -9
View File
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
}
/** Determine a color class based on the resolved type flags. */
/** Determine a color class based on the resolved type name + flags. */
function statusColorClass(op: SalesOpportunity): string {
if (op.closedFlag) return "status-closed";
if (op.closedFlag) {
const t = resolvedType(op);
if (t?.wonFlag) return "status-won";
if (t?.lostFlag) return "status-lost";
return "status-closed";
}
const t = resolvedType(op);
if (!t) return "status-open";
if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive";
const n = t.name.toLowerCase();
if (n.includes("future")) return "status-future";
if (n.includes("new")) return "status-new";
if (n.includes("review")) return "status-review";
if (n.includes("active")) return "status-active";
return "status-open";
}
@@ -253,8 +263,43 @@
return op.company?.name || "—";
}
function priorityLabel(op: SalesOpportunity): string {
return op.priority?.name || "—";
function ratingHeatClass(name: string | undefined): string {
if (!name) return "heat-neutral";
const n = name.toLowerCase();
if (n.includes("hot")) return "heat-hot";
if (n.includes("warm") || n.includes("medium")) return "heat-warm";
if (n.includes("cold") || n.includes("cool")) return "heat-cold";
return "heat-neutral";
}
function ratingHeatLevel(name: string | undefined): number {
if (!name) return 0;
const n = name.toLowerCase();
if (n.includes("hot")) return 3;
if (n.includes("warm") || n.includes("medium")) return 2;
if (n.includes("cold") || n.includes("cool")) return 1;
return 0;
}
function heatDotColor(name: string | undefined): string {
if (!name) return "rgba(128,128,128,0.4)";
const n = name.toLowerCase();
if (n.includes("hot")) return "#ef4444";
if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
return "rgba(128,128,128,0.4)";
}
function getDotStyle(level: number, ratingName: string | undefined): string {
const lvl = ratingHeatLevel(ratingName);
const filled = level <= lvl;
if (!filled)
return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;";
const color = heatDotColor(ratingName);
let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`;
if (ratingHeatClass(ratingName) === "heat-hot")
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s;
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
<th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-priority">Priority</th>
<th class="col-rating">Rating</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
@@ -441,10 +486,24 @@
{statusLabel(opp)}
</span>
</td>
<td class="col-priority">
<span class="sales-priority">
{priorityLabel(opp)}
</span>
<td class="col-rating">
{#if opp.rating?.name}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
></span>
{/each}
</span>
{opp.rating.name}
</span>
{:else}
{/if}
</td>
<td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close">
+68 -9
View File
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
}
/** Determine a color class based on the resolved type flags. */
/** Determine a color class based on the resolved type name + flags. */
function statusColorClass(op: SalesOpportunity): string {
if (op.closedFlag) return "status-closed";
if (op.closedFlag) {
const t = resolvedType(op);
if (t?.wonFlag) return "status-won";
if (t?.lostFlag) return "status-lost";
return "status-closed";
}
const t = resolvedType(op);
if (!t) return "status-open";
if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive";
const n = t.name.toLowerCase();
if (n.includes("future")) return "status-future";
if (n.includes("new")) return "status-new";
if (n.includes("review")) return "status-review";
if (n.includes("active")) return "status-active";
return "status-open";
}
@@ -253,8 +263,43 @@
return op.company?.name || "—";
}
function priorityLabel(op: SalesOpportunity): string {
return op.priority?.name || "—";
function ratingHeatClass(name: string | undefined): string {
if (!name) return "heat-neutral";
const n = name.toLowerCase();
if (n.includes("hot")) return "heat-hot";
if (n.includes("warm") || n.includes("medium")) return "heat-warm";
if (n.includes("cold") || n.includes("cool")) return "heat-cold";
return "heat-neutral";
}
function ratingHeatLevel(name: string | undefined): number {
if (!name) return 0;
const n = name.toLowerCase();
if (n.includes("hot")) return 3;
if (n.includes("warm") || n.includes("medium")) return 2;
if (n.includes("cold") || n.includes("cool")) return 1;
return 0;
}
function heatDotColor(name: string | undefined): string {
if (!name) return "rgba(128,128,128,0.4)";
const n = name.toLowerCase();
if (n.includes("hot")) return "#ef4444";
if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
return "rgba(128,128,128,0.4)";
}
function getDotStyle(level: number, ratingName: string | undefined): string {
const lvl = ratingHeatLevel(ratingName);
const filled = level <= lvl;
if (!filled)
return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;";
const color = heatDotColor(ratingName);
let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`;
if (ratingHeatClass(ratingName) === "heat-hot")
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s;
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
<th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-priority">Priority</th>
<th class="col-rating">Rating</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
@@ -441,10 +486,24 @@
{statusLabel(opp)}
</span>
</td>
<td class="col-priority">
<span class="sales-priority">
{priorityLabel(opp)}
</span>
<td class="col-rating">
{#if opp.rating?.name}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
></span>
{/each}
</span>
{opp.rating.name}
</span>
{:else}
{/if}
</td>
<td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close">
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes: [],
contacts: [],
products: [],
quotes: [],
accessToken: null,
permissions: {} as PermissionMap,
};
@@ -22,6 +23,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"notes",
"contacts",
"products",
"quotes",
]),
checkPermissions(accessToken, [
"sales.opportunity.fetch",
@@ -29,6 +31,11 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.note.create",
"sales.opportunity.note.update",
"sales.opportunity.note.delete",
"sales.opportunity.quote.fetch",
"sales.opportunity.quote.commit",
"sales.opportunity.quote.preview",
"sales.opportunity.quote.download",
"sales.opportunity.quote.fetch_downloads",
]),
]);
@@ -43,6 +50,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
const notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? [];
const quotes = result?.data?.quotes ?? [];
return {
opportunity,
@@ -50,6 +58,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes,
contacts,
products,
quotes,
accessToken,
permissions,
};
+39 -2
View File
@@ -11,6 +11,7 @@
import ContactsTab from "./components/ContactsTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
import ProductsTab from "./components/ProductsTab.svelte";
import QuotesTab from "./components/QuotesTab.svelte";
export let data: PageData;
@@ -19,6 +20,7 @@
$: notes = data.notes;
$: contacts = data.contacts;
$: products = data.products;
$: quotes = data.quotes ?? [];
$: permissions = data.permissions;
let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null;
@@ -48,6 +50,7 @@
const tabs = [
"Overview",
"Products",
"Quotes",
"Notes",
"Contacts",
"Activity",
@@ -55,6 +58,11 @@
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Hide Quotes tab if user lacks fetch permission
$: visibleTabs = tabs.filter(
(t) => t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false
);
// Track whether ProductsTab is in edit mode
let productsEditing = false;
@@ -126,7 +134,7 @@
<!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each tabs as tab}
{#each visibleTabs as tab}
<button
class="mobile-nav-item"
on:click={() => selectMobileTab(tab)}
@@ -183,6 +191,22 @@
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else if tab === "Quotes"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
@@ -206,6 +230,9 @@
{#if tab === "Contacts" && contacts.length > 0}
<span class="mobile-nav-badge">{contacts.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="mobile-nav-badge">{quotes.length}</span>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24"
@@ -252,7 +279,7 @@
{/if}
<div class="tab-bar" role="tablist">
{#each tabs as tab}
{#each visibleTabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
@@ -270,6 +297,9 @@
{#if tab === "Contacts" && contacts.length > 0}
<span class="tab-count-badge">{contacts.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="tab-count-badge">{quotes.length}</span>
{/if}
</button>
{/each}
</div>
@@ -293,6 +323,13 @@
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
/>
{:else if activeTab === "Quotes"}
<QuotesTab
accessToken={data.accessToken}
opportunityId={data.opportunityId}
initialQuotes={quotes}
{permissions}
/>
{:else if activeTab === "Notes"}
<NotesTab
{notes}
@@ -1,9 +1,34 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import { statusColorClass } from "../types";
import {
statusColorClass,
statusLabel,
isEquivalencyStatus,
originalStatusName,
formatDate,
} from "../types";
import { formatDate } from "../types";
// Days until expected close
$: isClosedOpportunity = (() => {
if (!opportunity) return false;
const statusText =
`${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
return (
!!opportunity.closedFlag ||
!!opportunity.closedDate ||
statusText.includes("won") ||
statusText.includes("lost")
);
})();
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
return Math.ceil(
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
(1000 * 60 * 60 * 24),
);
})();
export let opportunity: SalesOpportunity | null;
export let isMobile: boolean;
@@ -44,6 +69,53 @@
if (typeof closedBy === "string") return closedBy;
return closedBy.name ?? closedBy.identifier ?? String(closedBy.id ?? "");
}
/** Map rating name to a heat tier for visual styling */
function ratingHeatClass(name: string | undefined): string {
if (!name) return "heat-neutral";
const n = name.toLowerCase();
if (n.includes("hot")) return "heat-hot";
if (n.includes("warm") || n.includes("medium")) return "heat-warm";
if (n.includes("cold") || n.includes("cool")) return "heat-cold";
return "heat-neutral";
}
/** Map rating name to a thermometer icon fill level (0-3) */
function ratingHeatLevel(name: string | undefined): number {
if (!name) return 0;
const n = name.toLowerCase();
if (n.includes("hot")) return 3;
if (n.includes("warm") || n.includes("medium")) return 2;
if (n.includes("cold") || n.includes("cool")) return 1;
return 0;
}
/** Get the filled dot color for a rating heat tier */
function heatDotColor(name: string | undefined): string {
if (!name) return "rgba(128,128,128,0.4)";
const n = name.toLowerCase();
if (n.includes("hot")) return "#ef4444";
if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
return "rgba(128,128,128,0.4)";
}
/** Build the complete inline style for a heat dot */
function getDotStyle(level: number, ratingName: string | undefined): string {
const lvl = ratingHeatLevel(ratingName);
const filled = level <= lvl;
if (!filled)
return "background: rgba(128,128,128,0.25); width: 8px; height: 8px; border-radius: 50%; display: inline-block;";
const color = heatDotColor(ratingName);
const heat = ratingHeatClass(ratingName);
let s = `background: ${color}; width: 8px; height: 8px; border-radius: 50%; display: inline-block;`;
if (heat === "heat-hot") s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s;
}
/** Map probability percent to a tier for styling */
function probabilityTier(percent: number): string {
if (percent >= 75) return "prob-high";
if (percent >= 40) return "prob-mid";
return "prob-low";
}
</script>
<div
@@ -76,16 +148,98 @@
<span class="opp-number">#{opportunity.cwOpportunityId}</span>
{/if}
{#if opportunity.status}
<span class="opp-status-badge {statusColorClass(opportunity)}">
{opportunity.closedFlag ? "Closed" : opportunity.status.name}
<span
class="opp-status-badge {statusColorClass(opportunity)}"
class:status-equiv={isEquivalencyStatus(opportunity)}
data-tooltip={isEquivalencyStatus(opportunity)
? `Original: ${originalStatusName(opportunity)}`
: undefined}
>
{statusLabel(opportunity)}
</span>
{/if}
{#if opportunity.type?.name}
<span class="opp-type-badge">{opportunity.type.name}</span>
{/if}
{#if opportunity.type?.wonFlag || opportunity.type?.lostFlag}
<span
class="opp-outcome-badge {opportunity.type.wonFlag
? 'outcome-won'
: 'outcome-lost'}"
>
{opportunity.type.wonFlag ? "Won" : "Lost"}
</span>
{/if}
{#if opportunity.rating?.name}
<span
class="opp-rating-badge {ratingHeatClass(
opportunity.rating.name,
)}"
>
<span class="opp-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opportunity.rating.name)}
></span>
{/each}
</span>
{opportunity.rating.name}
</span>
{/if}
{#if opportunity.probability?.percent != null}
<span
class="opp-probability-badge {probabilityTier(
opportunity.probability.percent,
)}"
>
<svg
viewBox="0 0 20 20"
width="10"
height="10"
class="opp-prob-ring"
>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2.5"
opacity="0.15"
/>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-dasharray={`${opportunity.probability.percent * 0.5027} 50.27`}
stroke-linecap="round"
transform="rotate(-90 10 10)"
/>
</svg>
{opportunity.probability.percent}%
</span>
{/if}
</div>
</div>
<!-- ── Days to Close ── -->
{#if daysUntilClose !== null}
<div
class="opp-close-countdown"
class:overdue={daysUntilClose < 0}
class:soon={daysUntilClose >= 0 && daysUntilClose <= 14}
>
<span class="opp-close-number">{Math.abs(daysUntilClose)}</span>
<span class="opp-close-unit">
{daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? 's' : ''} overdue`
: `day${daysUntilClose !== 1 ? 's' : ''} to close`}
</span>
</div>
{/if}
<!-- ── Byline (Sales Rep) ── -->
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
<div class="opp-byline">
@@ -110,7 +110,8 @@
$: isClosedOpportunity = (() => {
if (!opportunity) return false;
const statusText = `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
const statusText =
`${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
return (
!!opportunity.closedFlag ||
!!opportunity.closedDate ||
@@ -129,16 +130,6 @@
return diff;
})();
$: closeOutcomeLabel = (() => {
const opp = opportunity;
if (!isClosedOpportunity || !opp) return null;
const outcomeText =
`${opp.status?.name ?? ""} ${opp.type?.name ?? ""}`.toLowerCase();
if (outcomeText.includes("won")) return "WON";
if (outcomeText.includes("lost")) return "LOST";
return "CLOSED";
})();
// Age in days
$: ageDays = (() => {
if (!opportunity?.createdAt) return null;
@@ -202,81 +193,7 @@
<div class="overview-tab">
<!-- ═══ Pipeline Banner ═══ -->
<div class="ov-pipeline-banner">
<div class="ov-pipeline-stages">
{#if opportunity?.stage?.name}
<div class="ov-pipeline-chip stage">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{opportunity.stage.name}
</div>
{/if}
{#if opportunity?.priority?.name}
<div class="ov-pipeline-chip priority">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
{opportunity.priority.name}
</div>
{/if}
{#if opportunity?.rating?.name}
<div class="ov-pipeline-chip rating">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{opportunity.rating.name}
</div>
{/if}
</div>
{#if closeOutcomeLabel || daysUntilClose !== null}
<div
class="ov-close-countdown"
class:overdue={!closeOutcomeLabel &&
daysUntilClose !== null &&
daysUntilClose < 0}
class:soon={!closeOutcomeLabel &&
daysUntilClose !== null &&
daysUntilClose >= 0 &&
daysUntilClose <= 14}
>
{#if closeOutcomeLabel}
<span class="ov-close-number">{closeOutcomeLabel}</span>
{#if closeOutcomeLabel !== "WON" && closeOutcomeLabel !== "LOST"}
<span class="ov-close-unit">Closed opportunity</span>
{/if}
{:else if daysUntilClose !== null}
<span class="ov-close-number">{Math.abs(daysUntilClose)}</span>
<span class="ov-close-unit">
{daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
: `day${daysUntilClose !== 1 ? "s" : ""} to close`}
</span>
{/if}
</div>
{/if}
<div class="ov-pipeline-stages"></div>
</div>
<!-- ═══ Financial KPI Strip ═══ -->
File diff suppressed because it is too large Load Diff
+87 -6
View File
@@ -148,12 +148,93 @@ export function formatCurrency(amount?: number | null): string {
}).format(amount);
}
/**
* Canonical status IDs pipeline tier.
* Equivalency IDs (legacy/pre-2024) are mapped to the same tier as their canonical parent.
*/
const STATUS_TIER: Record<number, string> = (() => {
const map: Record<number, string> = {};
// FutureLead (id 51) + equivalencies
for (const id of [51, 35, 36]) map[id] = "status-future";
// New (id 24) + equivalencies
for (const id of [24, 1, 13, 37]) map[id] = "status-new";
// Internal Review (id 56) + equivalencies
for (const id of [56, 10, 26, 27, 28, 41, 54]) map[id] = "status-review";
// Active (id 58) + equivalencies
for (const id of [
58, 9, 15, 16, 17, 18, 19, 20, 25, 43, 38, 39, 40, 42, 44, 45, 46, 47, 48,
52, 55, 57,
])
map[id] = "status-active";
// Won (id 29) + equivalencies
for (const id of [29, 2, 49]) map[id] = "status-won";
// Lost (id 53) + equivalencies
for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34, 50])
map[id] = "status-lost";
return map;
})();
/** Canonical display name for each tier */
const CANONICAL_NAMES: Record<number, string> = {
51: "FutureLead",
24: "New",
56: "Internal Review",
58: "Active",
29: "Won",
53: "Lost",
};
/** IDs that are canonical (not equivalency-mapped) */
const CANONICAL_IDS = new Set([51, 24, 56, 58, 29, 53]);
export function statusColorClass(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) return "status-closed";
const name = opportunity.status?.name?.toLowerCase();
if (!name) return "status-open";
if (name === "won") return "status-won";
if (name === "lost") return "status-lost";
if (name === "inactive") return "status-inactive";
if (opportunity.closedFlag) {
const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-closed";
}
const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-open";
}
/** Get the canonical display label for the status (resolves equivalencies). */
export function statusLabel(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) {
const sid = opportunity.status?.id;
if (sid != null) {
for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
const tier = STATUS_TIER[sid];
const canonTier = STATUS_TIER[Number(canonId)];
if (tier && tier === canonTier) return name;
}
}
return "Closed";
}
const sid = opportunity.status?.id;
if (sid != null) {
// If it IS the canonical ID, just use its name
if (CANONICAL_IDS.has(sid))
return CANONICAL_NAMES[sid] ?? opportunity.status?.name ?? "Open";
// Otherwise it's an equivalency — return the canonical name
const tier = STATUS_TIER[sid];
if (tier) {
for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
if (STATUS_TIER[Number(canonId)] === tier) return name;
}
}
}
return opportunity.status?.name ?? "Open";
}
/** Whether this status is an equivalency-mapped status (not canonical). */
export function isEquivalencyStatus(opportunity: SalesOpportunity): boolean {
const sid = opportunity.status?.id;
if (sid == null) return false;
return STATUS_TIER[sid] != null && !CANONICAL_IDS.has(sid);
}
/** The original CW status name (for tooltip on equivalency statuses). */
export function originalStatusName(opportunity: SalesOpportunity): string {
return opportunity.status?.name ?? "Unknown";
}