From 9145ea5ba49b57506235d3e83f7e0fac4c8b1d1e Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Sun, 1 Mar 2026 18:02:46 -0600 Subject: [PATCH] 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 --- src/components/AddProductModal.svelte | 157 +++++- .../optima-api/modules/api-modules.spec.ts | 7 +- src/lib/optima-api/modules/sales.ts | 38 ++ src/lib/permissions.spec.ts | 7 +- .../procurement/catalog/+page.server.ts | 2 +- .../sales/opportunity/[id]/+page.svelte | 3 +- .../[id]/components/OpportunitySidebar.svelte | 29 + .../[id]/components/OverviewTab.svelte | 533 +++++++++++++----- .../[id]/components/ProductsTab.svelte | 120 +++- src/styles/sales/opportunitydetail.css | 502 ++++++++++++++--- 10 files changed, 1135 insertions(+), 263 deletions(-) diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte index 7650129..75bf26e 100644 --- a/src/components/AddProductModal.svelte +++ b/src/components/AddProductModal.svelte @@ -10,7 +10,7 @@ export let isOpen = false; export let accessToken: string; - export let onSelect: (item: CatalogItem) => void = () => {}; + export let onSelect: (items: CatalogItem | CatalogItem[]) => void = () => {}; // ── Step state ── // "browse" → pick category, ecosystem, or search @@ -295,8 +295,9 @@ } function handleAddSelected() { - // Placeholder — TODO: implement bulk add - console.log("Add selected items:", cart); + if (cart.length === 0) return; + onSelect([...cart]); + handleClose(); } // ── Back / Home ── @@ -430,6 +431,30 @@ } } + // ── Quick-add special items ── + + function addSpecialOrder() { + const specialItem: CatalogItem = { + id: `special-order-${Date.now()}`, + identifier: "SPECIAL-ORDER", + description: "Special Order", + category: "Special Order", + }; + onSelect(specialItem); + handleClose(); + } + + function addLabor() { + const laborItem: CatalogItem = { + id: `labor-${Date.now()}`, + identifier: "LABOR", + description: "Labor", + category: "Labor", + }; + onSelect(laborItem); + handleClose(); + } + // ── Helpers ── function formatPrice(amount?: number | null): string { @@ -1012,6 +1037,62 @@ + +
Quick Actions
+
+ + +
+ {#if categories.length > 0}
Categories
@@ -1867,7 +1948,7 @@ /* ── Modal shell wrapper ── */ .modal-shell { position: relative; - height: 88vh; + height: 80vh; width: 94%; max-width: 720px; animation: modalIn 0.15s ease; @@ -2309,6 +2390,74 @@ flex-shrink: 0; } + /* ── Quick Actions ── */ + .quick-actions { + display: flex; + gap: 10px; + margin-bottom: 18px; + } + + .quick-action-btn { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: transparent; + cursor: pointer; + text-align: left; + transition: + background 0.12s, + border-color 0.12s, + box-shadow 0.12s; + } + + .quick-action-btn:hover { + background: var(--card-hover-bg); + border-color: var(--border-default, var(--border-subtle)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + } + + .quick-action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + flex-shrink: 0; + } + + .quick-action-icon.special-order-icon { + background: rgba(251, 191, 36, 0.1); + color: #f59e0b; + } + + .quick-action-icon.labor-icon { + background: rgba(52, 211, 153, 0.1); + color: #10b981; + } + + .quick-action-label { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .quick-action-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + } + + .quick-action-desc { + font-size: 11px; + color: var(--text-muted); + } + /* ── Category detail page ── */ .category-detail-page { display: flex; diff --git a/src/lib/optima-api/modules/api-modules.spec.ts b/src/lib/optima-api/modules/api-modules.spec.ts index c98760f..4154f4b 100644 --- a/src/lib/optima-api/modules/api-modules.spec.ts +++ b/src/lib/optima-api/modules/api-modules.spec.ts @@ -357,10 +357,9 @@ describe("optima api modules", () => { await sales.fetchOne("token", "opp-1"); - expect(mockApi.get).toHaveBeenCalledWith( - "/v1/sales/opportunities/opp-1", - { headers: { Authorization: "Bearer token" } }, - ); + expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", { + headers: { Authorization: "Bearer token" }, + }); }); it("sales.fetchForecasts calls forecasts endpoint", async () => { diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index 3ab89cb..50b57a1 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -77,6 +77,7 @@ export interface SalesOpportunity { closedFlag?: boolean; closedBy?: string | null; companyId?: string; + productSequence?: number[] | null; cwLastUpdated?: string | null; createdAt?: string; updatedAt?: string; @@ -97,6 +98,26 @@ export interface OpportunityType { optimaEquivalency?: number[]; } +export interface AddProductBody { + catalogItem?: { id: number }; + forecastDescription?: string; + productDescription?: string; + quantity?: number; + status?: { id: number }; + productClass?: string; + forecastType?: string; + revenue?: number; + cost?: number; + includeFlag?: boolean; + linkFlag?: boolean; + recurringFlag?: boolean; + taxableFlag?: boolean; + recurringRevenue?: number; + recurringCost?: number; + cycles?: number; + sequenceNumber?: number; +} + export const sales = { async fetchMany( accessToken: string, @@ -251,6 +272,23 @@ export const sales = { return response.data; }, + async addProduct( + accessToken: string, + identifier: string, + body: AddProductBody, + ) { + const response = await api.post( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + async refreshOpportunity(accessToken: string, identifier: string) { const response = await api.post( `/v1/sales/opportunities/${encodeURIComponent(identifier)}/refresh`, diff --git a/src/lib/permissions.spec.ts b/src/lib/permissions.spec.ts index 897b446..87fd5c9 100644 --- a/src/lib/permissions.spec.ts +++ b/src/lib/permissions.spec.ts @@ -71,10 +71,9 @@ describe("permissions helpers", () => { }); it("returns all-true with __checkFailed when accessToken is falsy", async () => { - const result = await checkPermissions( - undefined as unknown as string, - ["perm.a"], - ); + const result = await checkPermissions(undefined as unknown as string, [ + "perm.a", + ]); expect(result["perm.a"]).toBe(true); expect(result.__checkFailed).toBe(true); diff --git a/src/routes/procurement/catalog/+page.server.ts b/src/routes/procurement/catalog/+page.server.ts index 1b84ae1..59a257d 100644 --- a/src/routes/procurement/catalog/+page.server.ts +++ b/src/routes/procurement/catalog/+page.server.ts @@ -23,7 +23,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { try { const [result, permissions] = await Promise.all([ optima.procurement - .fetchMany(accessToken, page, search, 30, includeInactive) + .fetchMany(accessToken, page, { search, includeInactive }, 30) .catch((err) => { console.error( "Failed to fetch catalog items:", diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte index 36399ab..b9d3e59 100644 --- a/src/routes/sales/opportunity/[id]/+page.svelte +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -216,12 +216,13 @@
{#if activeTab === "Overview"} - + {:else if activeTab === "Products"} {:else if activeTab === "Notes"} {/if}
+ + + {:else}

Opportunity not found.

diff --git a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte index bf3925a..66ed62e 100644 --- a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte @@ -1,14 +1,84 @@
@@ -110,173 +212,298 @@ {/if}
- -
- {#if opportunity?.expectedCloseDate} -
-
- - - -
-
- Expected Close - {formatDate(opportunity.expectedCloseDate)} -
+ +
+
+ Revenue + {formatCurrency(totalRevenue)} + {activeProducts.length} line item{activeProducts.length !== 1 + ? "s" + : ""} +
+
+ Cost + {formatCurrency(totalCost)} +
+
+ Margin + {formatCurrency(totalMargin)} + {marginPct.toFixed(0)}% +
+ {#if totalTax > 0} +
+ Sales Tax + {formatCurrency(totalTax)}
{/if} - {#if opportunity?.totalSalesTax != null} -
-
- - - -
-
- Sales Tax - {formatCurrency(opportunity.totalSalesTax)} -
+
+ Total + {formatCurrency(grandTotal)} +
+ {#if hasCancellations} +
+ Cancelled + + {fullyCancelled.length + partiallyCancelled.length} + + + {#if fullyCancelled.length > 0}{fullyCancelled.length} full{/if}{#if fullyCancelled.length > 0 && partiallyCancelled.length > 0}, + {/if}{#if partiallyCancelled.length > 0}{partiallyCancelled.length} partial{/if} +
{/if} - {#if opportunity?.source} -
-
- - - -
-
- Source - {opportunity.source} -
-
- {/if} -
-
+
+ + +
+ +
+

- -

-
- Activity - {notes.length} notes · {contacts.length} contacts -
-
-
- - - {#if timeline.length > 0} -
-

Timeline

-
- {#each timeline as entry, i} -
-
-
- {entry.label} - {formatDate(entry.date)} + Timeline + {#if ageDays !== null} + Age: {ageDays}d + {/if} + + {#if timeline.length > 0} +
+ {#each timeline as entry, i} +
+
+
+ {entry.label} + {formatDate(entry.date)} +
-
- {/each} -
-
- {/if} + {/each} +
+ {:else} +

No timeline events yet.

+ {/if} - -
-

Details

-
- {#if opportunity?.cwOpportunityId} -
- CW Opportunity ID - {opportunity.cwOpportunityId} +
+
+ + + + {notes.length} Note{notes.length !== 1 ? "s" : ""} +
+
+ + + + {contacts.length} Contact{contacts.length !== 1 ? "s" : ""}
- {/if} - {#if opportunity?.customerPO} -
- Customer PO - {opportunity.customerPO} -
- {/if} - {#if opportunity?.campaign} -
- Campaign - {opportunity.campaign} -
- {/if} - {#if opportunity?.location?.name} -
- Location - {opportunity.location.name} -
- {/if} - {#if opportunity?.department?.name} -
- Department - {opportunity.department.name} -
- {/if} - {#if opportunity?.closedBy} -
- Closed By - {opportunity.closedBy} -
- {/if} -
- Last Synced - {formatDate(opportunity?.cwLastUpdated)} + {#if opportunity?.source} +
+ + + + {opportunity.source} +
+ {/if}
+ + +
+

+ + + + + Forecast Summary +

+ + {#if topProducts.length > 0} + +
+ + + + + + + + + + + {#each topProducts as p} + + + + + + + {/each} + + + + + + + + + +
ProductQtyRevenueMargin
+ + {p.catalogItem?.identifier ?? "—"} + {#if p.productDescription} + {p.productDescription} + {/if} + {#if p.cancellationType === "full"} + Cancelled + {:else if p.cancellationType === "partial"} + Partial + {/if} + + + {#if p.cancellationType === "partial"} + {effectiveQty(p)} + /{p.quantity} + {:else} + {p.quantity ?? "—"} + {/if} + {formatCurrency(p.revenue)} + {#if p.revenue && p.revenue > 0} + + {( + ((p.revenue - (p.cost ?? 0)) / p.revenue) * + 100 + ).toFixed(0)}% + + {:else} + + {/if} +
Subtotal{formatCurrency(totalRevenue)} + + {marginPct.toFixed(0)}% + +
+ {#if activeProducts.length > 15} +
+ +{activeProducts.length - 15} more item{activeProducts.length - + 15 !== + 1 + ? "s" + : ""} +
+ {/if} +
+ + + {#if classBreakdown.length > 1} +
+ By Product Class +
+ {#each classBreakdown as cls} +
+ {cls.name} +
+
+
+ {shortCurrency(cls.revenue)} +
+ {/each} +
+
+ {/if} + {:else} +

No products on this opportunity yet.

+ {/if} +
diff --git a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index a79fbef..0b3a84f 100644 --- a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -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 | 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) { diff --git a/src/styles/sales/opportunitydetail.css b/src/styles/sales/opportunitydetail.css index 842d000..05f12f0 100644 --- a/src/styles/sales/opportunitydetail.css +++ b/src/styles/sales/opportunitydetail.css @@ -435,12 +435,12 @@ } .tab-btn.active { - color: var(--accent-primary); + color: var(--text-primary); font-weight: 600; } .tab-btn.active::after { - background: var(--accent-primary); + background: var(--input-focus-border); } .tab-count-badge { @@ -459,7 +459,7 @@ } .tab-btn.active .tab-count-badge { - background: var(--accent-primary, var(--accent, #3b82f6)); + background: var(--input-focus-border); color: #fff; font-weight: 700; } @@ -478,7 +478,7 @@ .overview-tab { display: flex; flex-direction: column; - gap: 28px; + gap: 24px; } /* ── Pipeline Banner ── */ @@ -573,77 +573,100 @@ color: #dc2626; } -/* ── Metric Cards ── */ -.ov-metrics-row { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 12px; -} - -.ov-metric-card { +/* ── KPI Strip ── */ +.ov-kpi-strip { display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px; + gap: 2px; border-radius: 10px; - background: var(--card-bg); - border: 1px solid var(--card-border); - transition: border-color 0.15s; + overflow: hidden; + background: var(--border-subtle); } -.ov-metric-card:hover { - border-color: var(--card-hover-border); -} - -.ov-metric-icon { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 9px; - flex-shrink: 0; - color: #fff; -} - -.ov-metric-icon.close { - background: linear-gradient(135deg, #6366f1, #818cf8); -} - -.ov-metric-icon.money { - background: linear-gradient(135deg, #10b981, #34d399); -} - -.ov-metric-icon.source { - background: linear-gradient(135deg, #f59e0b, #fbbf24); -} - -.ov-metric-icon.activity { - background: linear-gradient(135deg, #0ea5e9, #38bdf8); -} - -.ov-metric-body { +.ov-kpi-card { + flex: 1; display: flex; flex-direction: column; - gap: 2px; + align-items: center; + gap: 1px; + padding: 10px 8px; + background: var(--bg-surface); min-width: 0; } -.ov-metric-label { - font-size: 11px; - font-weight: 500; +.ov-kpi-card.primary { + background: var(--nav-hover-bg); +} + +.ov-kpi-card.accent { + background: var(--nav-hover-bg); +} + +.ov-kpi-label { + font-size: 9px; + font-weight: 600; color: var(--text-muted); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.06em; } -.ov-metric-value { - font-size: 14px; - font-weight: 600; +.ov-kpi-value { + font-size: 13px; + font-weight: 700; color: var(--text-primary); + white-space: nowrap; } -/* ── Sections (Timeline, Details) ── */ +.ov-kpi-value.healthy { + color: #16a34a; +} +.ov-kpi-value.moderate { + color: #d97706; +} +.ov-kpi-value.low { + color: #ea580c; +} +.ov-kpi-value.negative { + color: #dc2626; +} + +.ov-kpi-pct { + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + border-radius: 4px; +} + +.ov-kpi-pct.healthy { + background: rgba(22, 163, 74, 0.1); + color: #16a34a; +} +.ov-kpi-pct.moderate { + background: rgba(217, 119, 6, 0.1); + color: #d97706; +} +.ov-kpi-pct.low { + background: rgba(234, 88, 12, 0.1); + color: #ea580c; +} +.ov-kpi-pct.negative { + background: rgba(220, 38, 38, 0.1); + color: #dc2626; +} + +.ov-kpi-sub { + font-size: 9px; + color: var(--text-secondary); +} + +/* ── Main Grid (Timeline + Forecast side by side) ── */ +.ov-main-grid { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 20px; + align-items: start; +} + +/* ── Sections ── */ .ov-section { display: flex; flex-direction: column; @@ -652,14 +675,39 @@ .ov-section-title { margin: 0; - font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; } +.ov-section-title svg { + flex-shrink: 0; + stroke: var(--text-muted); +} + +.ov-age-badge { + margin-left: auto; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + background: var(--nav-hover-bg); + color: var(--text-secondary); + text-transform: none; + letter-spacing: 0; +} + /* ── Timeline ── */ +.ov-timeline-section { + border-right: 1px solid var(--border-subtle); + padding-right: 20px; +} + .ov-timeline { display: flex; flex-direction: column; @@ -685,6 +733,10 @@ background: var(--border-subtle); } +.ov-timeline-item.highlight:not(.last)::before { + background: linear-gradient(to bottom, var(--accent), var(--border-subtle)); +} + .ov-timeline-dot { position: absolute; left: 3px; @@ -697,6 +749,11 @@ flex-shrink: 0; } +.ov-timeline-dot.highlight { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + .ov-timeline-item.last .ov-timeline-dot { background: var(--accent); } @@ -718,49 +775,316 @@ color: var(--text-secondary); } -/* ── Details Grid ── */ -.ov-details-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 10px; +.ov-timeline-item.highlight .ov-timeline-label { + font-weight: 600; } -.ov-detail { +/* ── Quick Stats ── */ +.ov-quick-stats { display: flex; - flex-direction: column; - gap: 3px; - padding: 10px 12px; - border-radius: 8px; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} + +.ov-quick-stat { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 6px; background: var(--nav-hover-bg); } -.ov-detail-label { - font-size: 11px; - font-weight: 500; +.ov-quick-stat svg { + flex-shrink: 0; + stroke: var(--text-muted); +} + +/* ── Forecast Table ── */ +.ov-forecast-section { + min-width: 0; +} + +.ov-forecast-table-wrap { + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; +} + +.ov-forecast-table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} + +.ov-forecast-table th { + text-align: left; + padding: 5px 8px; + font-size: 9.5px; + font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; + background: var(--nav-hover-bg); + border-bottom: 1px solid var(--border-subtle); } -.ov-detail-value { - font-size: 13px; - font-weight: 500; +.ov-forecast-table td { + padding: 4px 8px; color: var(--text-primary); - word-break: break-word; + border-bottom: 1px solid var(--border-subtle); + white-space: nowrap; } -.ov-detail-value.mono { +.ov-forecast-table tbody tr:last-child td { + border-bottom: 1px solid var(--border-subtle); +} + +.ov-forecast-table tbody tr:hover { + background: var(--nav-hover-bg); +} + +.ov-forecast-table tfoot td { + padding: 5px 8px; + font-size: 11.5px; + background: var(--nav-hover-bg); + border-bottom: none; +} + +.ov-forecast-table .col-product { + width: 40%; + min-width: 0; +} + +.ov-forecast-table .col-qty { + width: 10%; + text-align: center; +} + +.ov-forecast-table .col-revenue { + width: 25%; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ov-forecast-table .col-margin { + width: 15%; + text-align: center; +} + +.ov-product-id { + display: inline; + font-weight: 600; font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; - font-size: 12px; + font-size: 10.5px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; } -.overview-placeholder { +.ov-product-desc { + display: inline; + font-size: 10.5px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; + margin-left: 6px; +} + +.ov-margin-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; +} + +.ov-margin-badge.healthy { + background: rgba(22, 163, 74, 0.1); + color: #16a34a; +} +.ov-margin-badge.moderate { + background: rgba(217, 119, 6, 0.1); + color: #d97706; +} +.ov-margin-badge.low { + background: rgba(234, 88, 12, 0.1); + color: #ea580c; +} +.ov-margin-badge.negative { + background: rgba(220, 38, 38, 0.1); + color: #dc2626; +} +.ov-margin-badge.neutral { + background: var(--nav-hover-bg); + color: var(--text-muted); +} + +/* ── Cancellation styles — Forecast Table ── */ +.ov-row-cancelled-full td { + opacity: 0.45; + text-decoration: line-through; + text-decoration-color: var(--text-muted); +} +.ov-row-cancelled-full:hover td { + opacity: 0.65; +} +.ov-row-cancelled-partial { + border-left: 2px solid #d97706; +} + +.ov-cancel-badge { + display: inline-block; + padding: 0 4px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + vertical-align: middle; + margin-left: 4px; + line-height: 16px; + text-decoration: none; +} +.ov-cancel-badge.full { + background: rgba(220, 38, 38, 0.1); + color: #dc2626; +} +.ov-cancel-badge.partial { + background: rgba(217, 119, 6, 0.1); + color: #d97706; +} + +.ov-qty-effective { + font-weight: 600; +} +.ov-qty-original { + font-size: 9.5px; + color: var(--text-muted); +} + +.ov-product-inline { + display: inline; +} + +.cancelled-kpi .ov-kpi-value { + color: #dc2626; +} + +.ov-forecast-more { + padding: 6px 10px; + font-size: 11px; + color: var(--text-secondary); + text-align: center; + background: var(--nav-hover-bg); + border-top: 1px solid var(--border-subtle); +} + +/* ── Class Breakdown Bars ── */ +.ov-class-breakdown { + margin-top: 14px; +} + +.ov-class-breakdown-title { + display: block; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.ov-class-bars { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ov-class-row { + display: flex; + align-items: center; + gap: 8px; +} + +.ov-class-name { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + min-width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ov-class-bar-track { + flex: 1; + height: 6px; + border-radius: 3px; + background: var(--nav-hover-bg); + overflow: hidden; +} + +.ov-class-bar-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, #6366f1, #818cf8); + min-width: 2px; + transition: width 0.3s ease; +} + +.ov-class-amount { + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + min-width: 50px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ov-empty-note { margin: 0; font-size: 13px; color: var(--text-secondary); padding: 12px 0; } +/* ── Sidebar Footer (Details) ── */ +.opp-sidebar-footer { + margin-top: auto; + padding: 12px 20px 16px; + border-top: 1px solid var(--border-subtle); + display: flex; + flex-wrap: wrap; + gap: 4px 10px; +} + +.opp-footer-item { + font-size: 10px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; +} + +.opp-footer-item::after { + content: "\00b7"; + margin-left: 10px; + color: var(--text-muted); + opacity: 0.5; +} + +.opp-footer-item:last-child::after { + display: none; +} + /* ═══════════════════════════════════════════════════ Forecasts Tab ═══════════════════════════════════════════════════ */ @@ -1530,12 +1854,24 @@ color: var(--text-primary); } - .ov-metrics-row { + .ov-kpi-strip { + flex-wrap: wrap; + } + + .ov-kpi-card { + flex: 1 1 45%; + min-width: 80px; + } + + .ov-main-grid { grid-template-columns: 1fr; } - .ov-details-grid { - grid-template-columns: 1fr; + .ov-timeline-section { + border-right: none; + padding-right: 0; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 16px; } .contacts-grid {