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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user