feat(sales): enhance opportunity management and add CW integration
This commit is contained in:
@@ -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: [] })),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user