feat(sales): enhance opportunity management and add CW integration

This commit is contained in:
2026-03-07 18:16:14 -06:00
parent b735981b6b
commit 5169107a04
19 changed files with 4680 additions and 548 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ export const load: PageServerLoad = async ({ locals, url }) => {
},
};
}),
checkPermissions(accessToken, ["sales.opportunity.fetch.many"]),
checkPermissions(accessToken, [
"sales.opportunity.fetch.many",
"sales.opportunity.create",
]),
optima.sales
.fetchOpportunityTypes(accessToken)
.catch(() => ({ data: [] })),
+58 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
import "../../styles/sales/sales.css";
type SalesOpportunity = {
@@ -45,6 +47,9 @@
};
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
$: canCreate = data.permissions["sales.opportunity.create"] === true;
let showCreateModal = false;
// Build lookup maps for opportunity type resolution
// directMap: type id → OpportunityType (exact match)
@@ -390,6 +395,27 @@
{/if}
</div>
{#if canCreate}
<button
class="sales-create-btn"
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Opportunity
</button>
{/if}
<div class="sales-filter-wrap">
<button
class="sales-filter-btn"
@@ -447,7 +473,6 @@
<tr>
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-owner">Owner</th>
@@ -474,7 +499,6 @@
</div>
</td>
<td class="col-company">{companyLabel(opp)}</td>
<td class="col-stage">{opp.stage?.name || "—"}</td>
<td class="col-status">
<span
class="sales-status-badge {statusColorClass(opp)}"
@@ -583,6 +607,11 @@
</div>
{/if}
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
/>
<style>
.sales-access-denied {
display: flex;
@@ -613,4 +642,31 @@
text-align: center;
max-width: 360px;
}
.sales-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 1px 4px -1px rgba(59, 130, 246, 0.3);
white-space: nowrap;
}
.sales-create-btn:hover {
filter: brightness(1.08);
box-shadow: 0 3px 10px -2px rgba(59, 130, 246, 0.35);
transform: translateY(-0.5px);
}
.sales-create-btn:active {
transform: translateY(0);
}
</style>
+58 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales";
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
import "../../../styles/sales/sales.css";
type SalesOpportunity = {
@@ -45,6 +47,9 @@
};
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
$: canCreate = data.permissions["sales.opportunity.create"] === true;
let showCreateModal = false;
// Build lookup maps for opportunity type resolution
// directMap: type id → OpportunityType (exact match)
@@ -390,6 +395,27 @@
{/if}
</div>
{#if canCreate}
<button
class="sales-create-btn"
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Opportunity
</button>
{/if}
<div class="sales-filter-wrap">
<button
class="sales-filter-btn"
@@ -447,7 +473,6 @@
<tr>
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-owner">Owner</th>
@@ -474,7 +499,6 @@
</div>
</td>
<td class="col-company">{companyLabel(opp)}</td>
<td class="col-stage">{opp.stage?.name || "—"}</td>
<td class="col-status">
<span
class="sales-status-badge {statusColorClass(opp)}"
@@ -583,6 +607,11 @@
</div>
{/if}
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
/>
<style>
.sales-access-denied {
display: flex;
@@ -613,4 +642,31 @@
text-align: center;
max-width: 360px;
}
.sales-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 1px 4px -1px rgba(59, 130, 246, 0.3);
white-space: nowrap;
}
.sales-create-btn:hover {
filter: brightness(1.08);
box-shadow: 0 3px 10px -2px rgba(59, 130, 246, 0.35);
transform: translateY(-0.5px);
}
.sales-create-btn:active {
transform: translateY(0);
}
</style>
@@ -36,6 +36,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.quote.preview",
"sales.opportunity.quote.download",
"sales.opportunity.quote.fetch_downloads",
"sales.opportunity.view_margin",
"sales.opportunity.view_cost",
"sales.opportunity.update",
]),
]);
+11 -2
View File
@@ -60,7 +60,8 @@
// Hide Quotes tab if user lacks fetch permission
$: visibleTabs = tabs.filter(
(t) => t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false
(t) =>
t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false,
);
// Track whether ProductsTab is in edit mode
@@ -129,7 +130,14 @@
<div class="opportunity-detail-page">
<!-- Left pane — Opportunity overview -->
<OpportunitySidebar {opportunity} {isMobile} {mobileActiveTab} />
<OpportunitySidebar
{opportunity}
{isMobile}
{mobileActiveTab}
{permissions}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
<!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null}
@@ -319,6 +327,7 @@
{opportunityId}
productSequence={localProductSequence}
initialProductId={pendingProductId}
{permissions}
bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
File diff suppressed because it is too large Load Diff
@@ -123,9 +123,17 @@
// Days until expected close
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
const diff = Math.ceil(
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
(1000 * 60 * 60 * 24),
const raw = opportunity.expectedCloseDate;
const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
const now = new Date();
const closeDay = new Date(
close.getFullYear(),
close.getMonth(),
close.getDate(),
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diff = Math.round(
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
return diff;
})();
@@ -378,7 +386,6 @@
<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>
@@ -396,6 +403,19 @@
>
<td class="col-product">
<span class="ov-product-inline">
<span
class="ov-qty-badge"
class:partial={p.cancellationType === "partial"}
class:cancelled={p.cancellationType === "full"}
>
{#if p.cancellationType === "partial"}
{effectiveQty(p)}<span class="ov-qty-orig"
>/{p.quantity}</span
>
{:else}
{p.quantity ?? "—"}
{/if}
</span>
<span class="ov-product-id"
>{p.catalogItem?.identifier ?? "—"}</span
>
@@ -411,14 +431,6 @@
{/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.cost && p.cost > 0}
@@ -442,7 +454,6 @@
<tfoot>
<tr>
<td class="col-product"><strong>Subtotal</strong></td>
<td class="col-qty"></td>
<td class="col-revenue"
><strong>{formatCurrency(totalRevenue)}</strong></td
>
@@ -17,12 +17,17 @@
LaborStyle,
SpecialOrderBody,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
export let products: OpportunityProduct[];
export let accessToken: string | null;
export let opportunityId: string;
export let productSequence: number[] | null = null;
export let initialProductId: number | null = null;
export let permissions: PermissionMap = {} as PermissionMap;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
const dispatch = createEventDispatcher<{
sequenceSaved: number[];
@@ -531,9 +536,11 @@
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0);
$: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0);
$: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: markupPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0;
function marginHealthColor(revenue?: number, margin?: number): string {
/** Markup % = (price - cost) / cost × 100 */
function markupHealthColor(revenue?: number, margin?: number): string {
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return "neutral";
const pct = ((margin ?? 0) / cost) * 100;
@@ -543,19 +550,43 @@
return "negative";
}
function marginBarWidthPct(revenue?: number, margin?: number): number {
function markupBarWidthPct(revenue?: number, margin?: number): number {
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return 0;
const pct = ((margin ?? 0) / cost) * 100;
return Math.min(Math.abs(pct), 100);
}
function isNegativeMargin(revenue?: number, margin?: number): boolean {
function isNegativeMarkup(revenue?: number, margin?: number): boolean {
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return false;
return ((margin ?? 0) / cost) * 100 < 0;
}
/** Margin % = (price - cost) / price × 100 */
function marginHealthColor(revenue?: number, margin?: number): string {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return "neutral";
const pct = ((margin ?? 0) / rev) * 100;
if (pct >= 25) return "healthy";
if (pct >= 12) return "moderate";
if (pct >= 0) return "low";
return "negative";
}
function marginBarWidthPct(revenue?: number, margin?: number): number {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return 0;
const pct = ((margin ?? 0) / rev) * 100;
return Math.min(Math.abs(pct), 100);
}
function isNegativeMargin(revenue?: number, margin?: number): boolean {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return false;
return ((margin ?? 0) / rev) * 100 < 0;
}
function checkForChanges() {
hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]);
}
@@ -786,57 +817,62 @@
<span class="kpi-value">{formatCurrency(totalRevenue)}</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon cost">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
{#if canViewCost}
<div class="kpi-card">
<div class="kpi-icon cost">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M2 20h20" /><path d="M5 20V10l4-6 4 6v10" /><path
d="M19 20V4l-4 4"
/>
</svg>
</div>
<div class="kpi-content">
<span class="kpi-label">Cost</span>
<span class="kpi-value">{formatCurrency(totalCost)}</span>
</div>
</div>
{/if}
{#if canViewMargin}
<div class="kpi-card">
<div class="kpi-icon margin">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /><polyline
points="17 6 23 6 23 12"
/>
</svg>
</div>
<div class="kpi-content">
<span class="kpi-label">Margin</span>
<span class="kpi-value">{formatCurrency(totalMargin)}</span>
</div>
<span
class="kpi-badge {marginPct >= 25
? 'healthy'
: marginPct >= 12
? 'moderate'
: marginPct >= 0
? 'low'
: 'negative'}"
title="Margin: (price cost) ÷ price"
>
<path d="M2 20h20" /><path d="M5 20V10l4-6 4 6v10" /><path
d="M19 20V4l-4 4"
/>
</svg>
{marginPct.toFixed(1)}%
</span>
</div>
<div class="kpi-content">
<span class="kpi-label">Cost</span>
<span class="kpi-value">{formatCurrency(totalCost)}</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon margin">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /><polyline
points="17 6 23 6 23 12"
/>
</svg>
</div>
<div class="kpi-content">
<span class="kpi-label">Margin</span>
<span class="kpi-value">{formatCurrency(totalMargin)}</span>
</div>
<span
class="kpi-badge {marginPct >= 30
? 'healthy'
: marginPct >= 15
? 'moderate'
: marginPct >= 0
? 'low'
: 'negative'}"
>
{marginPct.toFixed(1)}%
</span>
</div>
{/if}
<div class="kpi-card">
<div class="kpi-icon profit">
<svg
@@ -1047,12 +1083,14 @@
>{formatCurrency(unitCost(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{/if}
</div>
<div class="card-badges">
@@ -1195,28 +1233,54 @@
>{formatCurrency(unitCost(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
<div class="card-margin-bar">
<div
class="card-margin-fill {marginHealthColor(
p.revenue,
p.margin,
)}"
class:from-right={isNegativeMargin(
p.revenue,
p.margin,
)}
style="width: {marginBarWidthPct(
p.revenue,
p.margin,
)}%"
></div>
</div>
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
<div class="card-bars">
<div class="card-bar-row">
<span class="card-bar-label">Markup</span>
<div class="card-margin-bar">
<div
class="card-margin-fill {markupHealthColor(
p.revenue,
p.margin,
)}"
class:from-right={isNegativeMarkup(
p.revenue,
p.margin,
)}
style="width: {markupBarWidthPct(
p.revenue,
p.margin,
)}%"
></div>
</div>
</div>
<div class="card-bar-row">
<span class="card-bar-label">Margin</span>
<div class="card-margin-bar">
<div
class="card-margin-fill {marginHealthColor(
p.revenue,
p.margin,
)}"
class:from-right={isNegativeMargin(
p.revenue,
p.margin,
)}
style="width: {marginBarWidthPct(
p.revenue,
p.margin,
)}%"
></div>
</div>
</div>
</div>
{/if}
</div>
</div>
@@ -1295,18 +1359,22 @@
>{formatCurrency(unitPrice(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{#if canViewCost}
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{/if}
</div>
<div class="card-badges">
@@ -1375,18 +1443,22 @@
>{formatCurrency(unitPrice(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{#if canViewCost}
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
<span class="card-metric-value"
>{formatCurrency(unitMargin(p))}</span
>
</div>
{/if}
</div>
</div>
<div class="card-chevron">
@@ -1726,36 +1798,40 @@
>
{/if}
</div>
<div class="detail-field">
<span class="detail-field-label">Unit Cost</span>
{#if isEditing}
<input
class="edit-input"
type="number"
min="0"
step="0.01"
bind:value={editForm.unitCost}
placeholder="0.00"
/>
{:else}
<span class="detail-field-value"
>{formatCurrency(unitCost(selectedProduct))}</span
>
{/if}
</div>
<div class="detail-field">
<span class="detail-field-label">Unit Margin</span>
<span class="detail-field-value"
>{#if isEditing}
{formatCurrency(
(parseFloat(editForm.unitPrice) || 0) -
(parseFloat(editForm.unitCost) || 0),
)}
{#if canViewCost}
<div class="detail-field">
<span class="detail-field-label">Unit Cost</span>
{#if isEditing}
<input
class="edit-input"
type="number"
min="0"
step="0.01"
bind:value={editForm.unitCost}
placeholder="0.00"
/>
{:else}
{formatCurrency(unitMargin(selectedProduct))}
{/if}</span
>
</div>
<span class="detail-field-value"
>{formatCurrency(unitCost(selectedProduct))}</span
>
{/if}
</div>
{/if}
{#if canViewMargin}
<div class="detail-field">
<span class="detail-field-label">Unit Margin</span>
<span class="detail-field-value"
>{#if isEditing}
{formatCurrency(
(parseFloat(editForm.unitPrice) || 0) -
(parseFloat(editForm.unitCost) || 0),
)}
{:else}
{formatCurrency(unitMargin(selectedProduct))}
{/if}</span
>
</div>
{/if}
</div>
</div>
@@ -1766,18 +1842,22 @@
>{formatCurrency(selectedProduct.revenue)}</span
>
</div>
<div class="detail-finance-card">
<span class="detail-finance-label">Cost</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.cost)}</span
>
</div>
<div class="detail-finance-card">
<span class="detail-finance-label">Margin</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.margin)}</span
>
</div>
{#if canViewCost}
<div class="detail-finance-card">
<span class="detail-finance-label">Cost</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.cost)}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="detail-finance-card">
<span class="detail-finance-label">Margin</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.margin)}</span
>
</div>
{/if}
<div class="detail-finance-card">
<span class="detail-finance-label">Profit</span>
<span class="detail-finance-value"
@@ -1786,46 +1866,92 @@
</div>
</div>
<div class="detail-field">
<span class="detail-field-label">Margin %</span>
<span class="detail-field-value">
{selectedProduct.cost
? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
<!-- Margin health bar -->
<div class="detail-margin-bar-wrap">
<div class="detail-margin-bar">
<div
class="detail-margin-fill {marginHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
)}"
class:from-right={isNegativeMargin(
selectedProduct.revenue,
selectedProduct.margin,
)}
style="width: {marginBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
)}%"
></div>
{#if canViewMargin}
<div class="detail-field-row">
<div class="detail-field">
<span class="detail-field-label">Markup %</span>
<span class="detail-field-value">
{selectedProduct.cost
? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
<div class="detail-field">
<span class="detail-field-label">Margin %</span>
<span class="detail-field-value">
{selectedProduct.revenue
? (
((selectedProduct.margin ?? 0) /
selectedProduct.revenue) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
</div>
<span class="detail-margin-pct">
{selectedProduct.cost
? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "% margin"
: "—"}
</span>
</div>
<!-- Markup health bar -->
<div class="detail-margin-bar-wrap">
<span class="detail-bar-label">Markup</span>
<div class="detail-margin-bar">
<div
class="detail-margin-fill {markupHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
)}"
class:from-right={isNegativeMarkup(
selectedProduct.revenue,
selectedProduct.margin,
)}
style="width: {markupBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
)}%"
></div>
</div>
<span class="detail-margin-pct">
{selectedProduct.cost
? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
<!-- Margin health bar -->
<div class="detail-margin-bar-wrap">
<span class="detail-bar-label">Margin</span>
<div class="detail-margin-bar">
<div
class="detail-margin-fill {marginHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
)}"
class:from-right={isNegativeMargin(
selectedProduct.revenue,
selectedProduct.margin,
)}
style="width: {marginBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
)}%"
></div>
</div>
<span class="detail-margin-pct">
{selectedProduct.revenue
? (
((selectedProduct.margin ?? 0) /
selectedProduct.revenue) *
100
).toFixed(1) + "%"
: "—"}
</span>
</div>
{/if}
</div>
<!-- Section: Details (text fields) -->
@@ -2000,12 +2126,16 @@
)}</span
>
</div>
<div class="detail-field">
<span class="detail-field-label">Recurring Cost</span>
<span class="detail-field-value"
>{formatCurrency(selectedProduct.recurringCost)}</span
>
</div>
{#if canViewCost}
<div class="detail-field">
<span class="detail-field-label">Recurring Cost</span>
<span class="detail-field-value"
>{formatCurrency(
selectedProduct.recurringCost,
)}</span
>
</div>
{/if}
<div class="detail-field">
<span class="detail-field-label">Cycles</span>
<span class="detail-field-value"
@@ -2316,6 +2446,7 @@
padding: 2px 6px;
border-radius: 6px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kpi-badge.healthy {
@@ -2851,14 +2982,38 @@
color: #3498db;
}
/* Margin health bar (inline in card) */
/* Margin & Markup bars (inline in card) */
.card-bars {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 60px;
align-self: center;
}
.card-bar-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-bar-label {
font-size: 9px;
font-weight: 600;
color: var(--text-faint, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
width: 38px;
flex-shrink: 0;
}
.card-margin-bar {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--bg-muted, rgba(128, 128, 128, 0.12));
overflow: hidden;
align-self: center;
min-width: 40px;
}
@@ -3616,6 +3771,26 @@
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.05));
}
/* Margin/Markup fields row */
.detail-field-row {
display: flex;
gap: 16px;
}
.detail-field-row .detail-field {
flex: 1;
}
.detail-bar-label {
font-size: 10px;
font-weight: 600;
color: var(--text-faint, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
width: 44px;
flex-shrink: 0;
}
/* Margin bar in detail */
.detail-margin-bar-wrap {
display: flex;
@@ -3663,6 +3838,12 @@
color: var(--text-secondary);
white-space: nowrap;
font-variant-numeric: tabular-nums;
min-width: 48px;
text-align: right;
}
.detail-margin-bar-wrap + .detail-margin-bar-wrap {
margin-top: 4px;
}
/* Flags */
+3 -1
View File
@@ -129,7 +129,9 @@ export function opportunityInitials(name: string): string {
export function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
// Append T00:00:00 to date-only strings so they parse as local midnight
const normalized = dateStr.includes("T") ? dateStr : dateStr + "T00:00:00";
return new Date(normalized).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",