feat(sales): cancellation awareness in forecast summary, productSequence ordering

- Show fully/partially cancelled products in forecast summary table
- Add cancellation KPI card with full/partial breakdown
- Fully cancelled rows: strikethrough + reduced opacity + red badge
- Partially cancelled rows: amber border + badge + effective/total qty
- Add productSequence prop to ProductsTab for custom ordering
- Fall back to CW sequenceNumber when no productSequence set
- Add productSequence field to SalesOpportunity interface
This commit is contained in:
2026-03-01 18:02:46 -06:00
parent 4bec198db6
commit 9145ea5ba4
10 changed files with 1135 additions and 263 deletions
@@ -216,12 +216,13 @@
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab {opportunity} {notes} {contacts} />
<OverviewTab {opportunity} {notes} {contacts} {products} />
{:else if activeTab === "Products"}
<ProductsTab
{products}
accessToken={data.accessToken}
{opportunityId}
productSequence={opportunity?.productSequence ?? null}
/>
{:else if activeTab === "Notes"}
<NotesTab
@@ -3,6 +3,8 @@
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import { statusColorClass } from "../types";
import { formatDate } from "../types";
export let opportunity: SalesOpportunity | null;
export let isMobile: boolean;
export let mobileActiveTab: string | null;
@@ -276,6 +278,33 @@
</div>
{/if}
</div>
<!-- ── Details Footer ── -->
<div class="opp-sidebar-footer">
{#if opportunity.cwOpportunityId}
<span class="opp-footer-item">CW #{opportunity.cwOpportunityId}</span>
{/if}
{#if opportunity.customerPO}
<span class="opp-footer-item">PO: {opportunity.customerPO}</span>
{/if}
{#if opportunity.location?.name}
<span class="opp-footer-item">{opportunity.location.name}</span>
{/if}
{#if opportunity.department?.name}
<span class="opp-footer-item">{opportunity.department.name}</span>
{/if}
{#if opportunity.campaign}
<span class="opp-footer-item">{opportunity.campaign}</span>
{/if}
{#if opportunity.closedBy}
<span class="opp-footer-item">Closed by {opportunity.closedBy}</span>
{/if}
{#if opportunity.cwLastUpdated}
<span class="opp-footer-item"
>Synced {formatDate(opportunity.cwLastUpdated)}</span
>
{/if}
</div>
{:else}
<div class="opp-sidebar-empty">
<p>Opportunity not found.</p>
@@ -1,14 +1,84 @@
<script lang="ts">
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { OpportunityNote, OpportunityContact } from "../types";
import type {
OpportunityNote,
OpportunityContact,
OpportunityProduct,
} from "../types";
import { formatDate, formatCurrency, statusColorClass } from "../types";
export let opportunity: SalesOpportunity | null;
export let notes: OpportunityNote[];
export let contacts: OpportunityContact[];
export let products: OpportunityProduct[];
// Timeline entries — built dynamically from available dates
// ── Active (non-cancelled) products ──
$: activeProducts = products.filter((p) => p.cancellationType !== "full");
// ── Cancellation stats ──
$: fullyCancelled = products.filter((p) => p.cancellationType === "full");
$: partiallyCancelled = products.filter(
(p) => p.cancellationType === "partial",
);
$: hasCancellations =
fullyCancelled.length > 0 || partiallyCancelled.length > 0;
// ── Financial KPIs from products ──
$: totalRevenue = activeProducts.reduce((s, p) => s + (p.revenue ?? 0), 0);
$: totalCost = activeProducts.reduce((s, p) => s + (p.cost ?? 0), 0);
$: totalMargin = totalRevenue - totalCost;
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0;
$: totalTax = opportunity?.totalSalesTax ?? 0;
$: grandTotal = totalRevenue + totalTax;
// ── Product class breakdown ──
$: classBreakdown = (() => {
const map = new Map<
string,
{ revenue: number; cost: number; count: number }
>();
for (const p of activeProducts) {
const cls = p.productClass || "Other";
const entry = map.get(cls) ?? { revenue: 0, cost: 0, count: 0 };
entry.revenue += p.revenue ?? 0;
entry.cost += p.cost ?? 0;
entry.count++;
map.set(cls, entry);
}
return [...map.entries()]
.sort((a, b) => b[1].revenue - a[1].revenue)
.map(([name, data]) => ({
name,
...data,
margin: data.revenue - data.cost,
marginPct:
data.revenue > 0
? ((data.revenue - data.cost) / data.revenue) * 100
: 0,
}));
})();
// ── Top products by revenue (up to 15), active first then cancelled ──
$: topProducts = (() => {
const active = [...activeProducts]
.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0))
.slice(0, 15);
const cancelled = [...fullyCancelled]
.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0))
.slice(0, Math.max(0, 15 - active.length));
return [...active, ...cancelled];
})();
/** Effective quantity after cancellations */
function effectiveQty(p: OpportunityProduct): number {
return (p.quantity ?? 0) - (p.quantityCancelled ?? 0);
}
// ── Timeline entries — built dynamically from available dates ──
$: timeline = [
opportunity?.createdAt
? { label: "Created", date: opportunity.createdAt, icon: "created" }
: null,
opportunity?.dateBecameLead
? { label: "Became Lead", date: opportunity.dateBecameLead, icon: "lead" }
: null,
@@ -24,12 +94,18 @@
label: "Expected Close",
date: opportunity.expectedCloseDate,
icon: "target",
highlight: true,
}
: null,
opportunity?.closedDate
? { label: "Closed", date: opportunity.closedDate, icon: "closed" }
: null,
].filter(Boolean) as { label: string; date: string; icon: string }[];
].filter(Boolean) as {
label: string;
date: string;
icon: string;
highlight?: boolean;
}[];
// Days until expected close
$: daysUntilClose = (() => {
@@ -40,6 +116,32 @@
);
return diff;
})();
// Age in days
$: ageDays = (() => {
if (!opportunity?.createdAt) return null;
return Math.floor(
(Date.now() - new Date(opportunity.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
);
})();
function marginHealthColor(pct: number): string {
if (pct >= 30) return "healthy";
if (pct >= 15) return "moderate";
if (pct >= 0) return "low";
return "negative";
}
function shortCurrency(amount: number): string {
if (Math.abs(amount) >= 1_000_000) {
return `$${(amount / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(amount) >= 1_000) {
return `$${(amount / 1_000).toFixed(1)}K`;
}
return formatCurrency(amount);
}
</script>
<div class="overview-tab">
@@ -110,173 +212,298 @@
{/if}
</div>
<!-- ═══ Deal Metrics ═══ -->
<div class="ov-metrics-row">
{#if opportunity?.expectedCloseDate}
<div class="ov-metric-card">
<div class="ov-metric-icon close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Expected Close</span>
<span class="ov-metric-value"
>{formatDate(opportunity.expectedCloseDate)}</span
>
</div>
<!-- ═══ Financial KPI Strip ═══ -->
<div class="ov-kpi-strip">
<div class="ov-kpi-card primary">
<span class="ov-kpi-label">Revenue</span>
<span class="ov-kpi-value">{formatCurrency(totalRevenue)}</span>
<span class="ov-kpi-sub"
>{activeProducts.length} line item{activeProducts.length !== 1
? "s"
: ""}</span
>
</div>
<div class="ov-kpi-card">
<span class="ov-kpi-label">Cost</span>
<span class="ov-kpi-value">{formatCurrency(totalCost)}</span>
</div>
<div class="ov-kpi-card">
<span class="ov-kpi-label">Margin</span>
<span class="ov-kpi-value {marginHealthColor(marginPct)}"
>{formatCurrency(totalMargin)}</span
>
<span class="ov-kpi-pct {marginHealthColor(marginPct)}"
>{marginPct.toFixed(0)}%</span
>
</div>
{#if totalTax > 0}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Sales Tax</span>
<span class="ov-kpi-value">{formatCurrency(totalTax)}</span>
</div>
{/if}
{#if opportunity?.totalSalesTax != null}
<div class="ov-metric-card">
<div class="ov-metric-icon money">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<line x1="12" y1="1" x2="12" y2="23" /><path
d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Sales Tax</span>
<span class="ov-metric-value"
>{formatCurrency(opportunity.totalSalesTax)}</span
>
</div>
<div class="ov-kpi-card accent">
<span class="ov-kpi-label">Total</span>
<span class="ov-kpi-value">{formatCurrency(grandTotal)}</span>
</div>
{#if hasCancellations}
<div class="ov-kpi-card cancelled-kpi">
<span class="ov-kpi-label">Cancelled</span>
<span class="ov-kpi-value">
{fullyCancelled.length + partiallyCancelled.length}
</span>
<span class="ov-kpi-sub">
{#if fullyCancelled.length > 0}{fullyCancelled.length} full{/if}{#if fullyCancelled.length > 0 && partiallyCancelled.length > 0},
{/if}{#if partiallyCancelled.length > 0}{partiallyCancelled.length} partial{/if}
</span>
</div>
{/if}
{#if opportunity?.source}
<div class="ov-metric-card">
<div class="ov-metric-icon source">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Source</span>
<span class="ov-metric-value">{opportunity.source}</span>
</div>
</div>
{/if}
<div class="ov-metric-card">
<div class="ov-metric-icon activity">
</div>
<!-- ═══ Two-column layout: Timeline + Forecast ═══ -->
<div class="ov-main-grid">
<!-- Left: Timeline -->
<div class="ov-section ov-timeline-section">
<h3 class="ov-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
width="15"
height="15"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
<circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Activity</span>
<span class="ov-metric-value"
>{notes.length} notes · {contacts.length} contacts</span
>
</div>
</div>
</div>
<!-- ═══ Timeline ═══ -->
{#if timeline.length > 0}
<div class="ov-section">
<h3 class="ov-section-title">Timeline</h3>
<div class="ov-timeline">
{#each timeline as entry, i}
<div class="ov-timeline-item" class:last={i === timeline.length - 1}>
<div class="ov-timeline-dot"></div>
<div class="ov-timeline-content">
<span class="ov-timeline-label">{entry.label}</span>
<span class="ov-timeline-date">{formatDate(entry.date)}</span>
Timeline
{#if ageDays !== null}
<span class="ov-age-badge">Age: {ageDays}d</span>
{/if}
</h3>
{#if timeline.length > 0}
<div class="ov-timeline">
{#each timeline as entry, i}
<div
class="ov-timeline-item"
class:last={i === timeline.length - 1}
class:highlight={entry.highlight}
>
<div
class="ov-timeline-dot"
class:highlight={entry.highlight}
></div>
<div class="ov-timeline-content">
<span class="ov-timeline-label">{entry.label}</span>
<span class="ov-timeline-date">{formatDate(entry.date)}</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{:else}
<p class="ov-empty-note">No timeline events yet.</p>
{/if}
<!-- ═══ Details ═══ -->
<div class="ov-section">
<h3 class="ov-section-title">Details</h3>
<div class="ov-details-grid">
{#if opportunity?.cwOpportunityId}
<div class="ov-detail">
<span class="ov-detail-label">CW Opportunity ID</span>
<span class="ov-detail-value mono">{opportunity.cwOpportunityId}</span
<!-- Quick Stats under Timeline -->
<div class="ov-quick-stats">
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<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" />
</svg>
<span>{notes.length} Note{notes.length !== 1 ? "s" : ""}</span>
</div>
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/>
</svg>
<span
>{contacts.length} Contact{contacts.length !== 1 ? "s" : ""}</span
>
</div>
{/if}
{#if opportunity?.customerPO}
<div class="ov-detail">
<span class="ov-detail-label">Customer PO</span>
<span class="ov-detail-value">{opportunity.customerPO}</span>
</div>
{/if}
{#if opportunity?.campaign}
<div class="ov-detail">
<span class="ov-detail-label">Campaign</span>
<span class="ov-detail-value">{opportunity.campaign}</span>
</div>
{/if}
{#if opportunity?.location?.name}
<div class="ov-detail">
<span class="ov-detail-label">Location</span>
<span class="ov-detail-value">{opportunity.location.name}</span>
</div>
{/if}
{#if opportunity?.department?.name}
<div class="ov-detail">
<span class="ov-detail-label">Department</span>
<span class="ov-detail-value">{opportunity.department.name}</span>
</div>
{/if}
{#if opportunity?.closedBy}
<div class="ov-detail">
<span class="ov-detail-label">Closed By</span>
<span class="ov-detail-value">{opportunity.closedBy}</span>
</div>
{/if}
<div class="ov-detail">
<span class="ov-detail-label">Last Synced</span>
<span class="ov-detail-value"
>{formatDate(opportunity?.cwLastUpdated)}</span
>
{#if opportunity?.source}
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
<span>{opportunity.source}</span>
</div>
{/if}
</div>
</div>
<!-- Right: Condensed Forecast -->
<div class="ov-section ov-forecast-section">
<h3 class="ov-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path
d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"
/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line
x1="12"
y1="22.08"
x2="12"
y2="12"
/>
</svg>
Forecast Summary
</h3>
{#if topProducts.length > 0}
<!-- Top Products mini-table -->
<div class="ov-forecast-table-wrap">
<table class="ov-forecast-table">
<thead>
<tr>
<th class="col-product">Product</th>
<th class="col-qty">Qty</th>
<th class="col-revenue">Revenue</th>
<th class="col-margin">Margin</th>
</tr>
</thead>
<tbody>
{#each topProducts as p}
<tr
class:ov-row-cancelled-full={p.cancellationType === "full"}
class:ov-row-cancelled-partial={p.cancellationType ===
"partial"}
>
<td class="col-product">
<span class="ov-product-inline">
<span class="ov-product-id"
>{p.catalogItem?.identifier ?? "—"}</span
>
{#if p.productDescription}
<span class="ov-product-desc"
>{p.productDescription}</span
>
{/if}
{#if p.cancellationType === "full"}
<span class="ov-cancel-badge full">Cancelled</span>
{:else if p.cancellationType === "partial"}
<span class="ov-cancel-badge partial">Partial</span>
{/if}
</span>
</td>
<td class="col-qty">
{#if p.cancellationType === "partial"}
<span class="ov-qty-effective">{effectiveQty(p)}</span>
<span class="ov-qty-original">/{p.quantity}</span>
{:else}
{p.quantity ?? "—"}
{/if}
</td>
<td class="col-revenue">{formatCurrency(p.revenue)}</td>
<td class="col-margin">
{#if p.revenue && p.revenue > 0}
<span
class="ov-margin-badge {marginHealthColor(
((p.revenue - (p.cost ?? 0)) / p.revenue) * 100,
)}"
>
{(
((p.revenue - (p.cost ?? 0)) / p.revenue) *
100
).toFixed(0)}%
</span>
{:else}
<span class="ov-margin-badge neutral"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td class="col-product"><strong>Subtotal</strong></td>
<td class="col-qty"></td>
<td class="col-revenue"
><strong>{formatCurrency(totalRevenue)}</strong></td
>
<td class="col-margin">
<span class="ov-margin-badge {marginHealthColor(marginPct)}">
{marginPct.toFixed(0)}%
</span>
</td>
</tr>
</tfoot>
</table>
{#if activeProducts.length > 15}
<div class="ov-forecast-more">
+{activeProducts.length - 15} more item{activeProducts.length -
15 !==
1
? "s"
: ""}
</div>
{/if}
</div>
<!-- Class Breakdown -->
{#if classBreakdown.length > 1}
<div class="ov-class-breakdown">
<span class="ov-class-breakdown-title">By Product Class</span>
<div class="ov-class-bars">
{#each classBreakdown as cls}
<div class="ov-class-row">
<span class="ov-class-name">{cls.name}</span>
<div class="ov-class-bar-track">
<div
class="ov-class-bar-fill"
style="width: {totalRevenue > 0
? (cls.revenue / totalRevenue) * 100
: 0}%"
></div>
</div>
<span class="ov-class-amount"
>{shortCurrency(cls.revenue)}</span
>
</div>
{/each}
</div>
</div>
{/if}
{:else}
<p class="ov-empty-note">No products on this opportunity yet.</p>
{/if}
</div>
</div>
</div>
@@ -6,17 +6,89 @@
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
import AddProductModal from "../../../../../components/AddProductModal.svelte";
import type { CatalogItem } from "$lib/optima-api/modules/procurement";
import type { AddProductBody } from "$lib/optima-api/modules/sales";
export let products: OpportunityProduct[];
export let accessToken: string | null;
export let opportunityId: string;
export let productSequence: number[] | null = null;
let showAddProductModal = false;
function handleProductSelected(item: CatalogItem) {
// TODO: wire up to the API to actually add the product to the opportunity
console.log("[AddProduct] Selected catalog item:", item);
showAddProductModal = false;
let isAddingProduct = false;
let addProductError = "";
function buildProductBody(item: CatalogItem): AddProductBody {
const body: AddProductBody = {};
// For catalog items with a CW catalog ID, link them
if (item.cwCatalogId) {
body.catalogItem = { id: item.cwCatalogId };
}
// Use description for forecast/product description
if (item.description) {
body.forecastDescription = item.description;
body.productDescription = item.description;
}
// Default quantity of 1
const qty = 1;
body.quantity = qty;
// Use the raw catalog unit price/cost — not adjusted by cancellations
// CW expects revenue/cost as totals (unit × quantity)
if (item.price != null) body.revenue = item.price * qty;
if (item.cost != null) body.cost = item.cost * qty;
return body;
}
async function refreshProducts() {
if (!accessToken) return;
try {
const result = await optima.sales.fetchProducts(
accessToken,
opportunityId,
);
if (result?.data) {
products = result.data;
}
} catch (err) {
console.error("[Products] Failed to refresh:", err);
}
}
async function handleProductSelected(incoming: CatalogItem | CatalogItem[]) {
if (!accessToken) return;
const items = Array.isArray(incoming) ? incoming : [incoming];
if (items.length === 0) return;
isAddingProduct = true;
addProductError = "";
try {
// Add all items in parallel
await Promise.all(
items.map((item) =>
optima.sales.addProduct(
accessToken!,
opportunityId,
buildProductBody(item),
),
),
);
// Re-fetch the full product list so everything is in sync
await refreshProducts();
showAddProductModal = false;
} catch (err) {
addProductError =
err instanceof Error ? err.message : "Failed to add product";
console.error("[AddProduct] Failed:", err);
} finally {
isAddingProduct = false;
}
}
let selectedProduct: OpportunityProduct | null = null;
@@ -81,9 +153,29 @@
// Initialize when the products prop changes (but NOT when activeProducts is reassigned by drag)
function initProducts(incoming: OpportunityProduct[]) {
const sorted = [...incoming].sort(
(a, b) => (a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity),
);
let sorted: OpportunityProduct[];
if (productSequence && productSequence.length > 0) {
// Use the locally-stored product sequence for ordering
const idxMap = new Map(productSequence.map((id, i) => [id, i]));
sorted = [...incoming].sort((a, b) => {
const ai = idxMap.get(a.id);
const bi = idxMap.get(b.id);
// Items not in the sequence go to the end, ordered by CW sequenceNumber
if (ai == null && bi == null)
return (
(a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity)
);
if (ai == null) return 1;
if (bi == null) return -1;
return ai - bi;
});
} else {
// Fallback: CW sequenceNumber ordering
sorted = [...incoming].sort(
(a, b) =>
(a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity),
);
}
orderedProducts = sorted;
activeProducts = sorted.filter((p) => !isCancelled(p));
originalOrderIds = activeProducts.map((p) => p.id);
@@ -188,20 +280,22 @@
orderedIds,
);
const returnedProducts: OpportunityProduct[] | undefined =
result?.data?.products;
const idMap: Record<string, number> | undefined = result?.data?.idMap;
if (returnedProducts?.length) {
activeProducts = returnedProducts.filter((p) => !isCancelled(p));
cancelledProducts = returnedProducts.filter((p) => isCancelled(p));
} else if (idMap) {
// Only update the IDs — no other product values should change
if (idMap) {
activeProducts = activeProducts.map((p) => {
const newId = idMap[String(p.id)];
return newId != null ? { ...p, id: newId } : p;
});
}
// Update sequence numbers locally to reflect the new order
activeProducts = activeProducts.map((p, i) => ({
...p,
sequenceNumber: i + 1,
}));
originalOrderIds = activeProducts.map((p) => p.id);
hasChanges = false;
} catch (err: unknown) {