feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user