feat: enhance opportunity detail and sales flow

This commit is contained in:
2026-03-03 19:46:12 -06:00
parent 9145ea5ba4
commit c628a78b27
13 changed files with 1030 additions and 88 deletions
+2
View File
@@ -26,4 +26,6 @@ vite.config.ts.timestamp-*
out out
tailwindcss-*.log tailwindcss-*.log
api-calls.jsonl
opportunity-debug.json
pnpm-lock.yaml pnpm-lock.yaml
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { goto } from "$app/navigation";
const INTERVAL_MS = 60_000; // 60 seconds
let timer: ReturnType<typeof setInterval> | null = null;
async function checkSession() {
try {
const res = await fetch("/api/auth/check", {
credentials: "same-origin",
});
if (!res.ok) {
// Session is dead — redirect to login
console.warn("Session expired or invalid — redirecting to login.");
goto("/login");
}
} catch (err) {
// Network error (API unreachable, etc.) — don't redirect on transient
// failures; the next tick will retry.
console.warn("Session check failed (network error):", err);
}
}
onMount(() => {
// Run the first check immediately, then every 60 s
checkSession();
timer = setInterval(checkSession, INTERVAL_MS);
});
onDestroy(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
+10 -1
View File
@@ -148,10 +148,19 @@ export const sales = {
return response.data; return response.data;
}, },
async fetchOne(accessToken: string, identifier: string) { async fetchOne(
accessToken: string,
identifier: string,
include?: ("notes" | "contacts" | "products")[],
) {
const params: Record<string, string> = {};
if (include && include.length > 0) {
params.include = include.join(",");
}
const response = await api.get( const response = await api.get(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}`, `/v1/sales/opportunities/${encodeURIComponent(identifier)}`,
{ {
params,
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
+2
View File
@@ -4,6 +4,7 @@
import { navigating } from "$app/stores"; import { navigating } from "$app/stores";
import { theme } from "$lib/theme"; import { theme } from "$lib/theme";
import LoadingSpinner from "../components/LoadingSpinner.svelte"; import LoadingSpinner from "../components/LoadingSpinner.svelte";
import SessionGuard from "../components/SessionGuard.svelte";
const navItems = [ const navItems = [
{ {
@@ -45,6 +46,7 @@
{#if $page.route.id?.startsWith("/(auth)")} {#if $page.route.id?.startsWith("/(auth)")}
<slot /> <slot />
{:else} {:else}
<SessionGuard />
<LoadingSpinner loading={!!$navigating} /> <LoadingSpinner loading={!!$navigating} />
<div class="layout-container"> <div class="layout-container">
<header class="header"> <header class="header">
+25
View File
@@ -0,0 +1,25 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/**
* Lightweight endpoint polled by the client every 60 seconds.
*
* The server-side `handle` hook in hooks.server.ts already runs before this
* handler is reached. That hook:
* 1. Validates the access token JWT expiry
* 2. Refreshes the token pair when the access token is within 60 s of expiry
* 3. Redirects to /login (303) if both tokens are unusable
*
* So by the time we get here we know the session is still alive — we just
* return a 200 with a minimal body. If the hook redirected, the client fetch
* will see a non-200 (or a redirect to /login) and can react accordingly.
*/
export const GET: RequestHandler = async ({ locals }) => {
const accessToken = locals.session?.accessToken ?? null;
if (!accessToken) {
return json({ authenticated: false }, { status: 401 });
}
return json({ authenticated: true });
};
+2 -2
View File
@@ -344,10 +344,10 @@
} }
} }
// Compute margin from price/cost // Compute margin (markup %) from price/cost
function computeMargin(price?: number, cost?: number): string { function computeMargin(price?: number, cost?: number): string {
if (price == null || cost == null || cost === 0) return "—"; if (price == null || cost == null || cost === 0) return "—";
const margin = ((price - cost) / price) * 100; const margin = ((price - cost) / cost) * 100;
return `${margin.toFixed(1)}%`; return `${margin.toFixed(1)}%`;
} }
@@ -17,23 +17,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
} }
try { try {
const [ const [result, permissions] = await Promise.all([
opportunityResult, optima.sales.fetchOne(accessToken, params.id, [
notesResult, "notes",
contactsResult, "contacts",
productsResult, "products",
permissions, ]),
] = await Promise.all([
optima.sales.fetchOne(accessToken, params.id),
optima.sales
.fetchNotes(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.sales
.fetchContacts(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.sales
.fetchProducts(accessToken, params.id)
.catch(() => ({ data: [] })),
checkPermissions(accessToken, [ checkPermissions(accessToken, [
"sales.opportunity.fetch", "sales.opportunity.fetch",
"sales.opportunity.refresh", "sales.opportunity.refresh",
@@ -43,15 +32,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
]), ]),
]); ]);
const opportunity = opportunityResult?.data ?? null; const { writeFileSync } = await import("fs");
const products = productsResult?.data ?? []; const { resolve } = await import("path");
console.log("[Products]", JSON.stringify(products, null, 2)); writeFileSync(
resolve("opportunity-debug.json"),
JSON.stringify(result, null, 2),
);
const opportunity = result?.data ?? null;
const notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? [];
return { return {
opportunity, opportunity,
opportunityId: params.id, opportunityId: params.id,
notes: notesResult?.data ?? [], notes,
contacts: contactsResult?.data ?? [], contacts,
products, products,
accessToken, accessToken,
permissions, permissions,
+45 -2
View File
@@ -43,15 +43,50 @@
type Tab = (typeof tabs)[number]; type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview"; let activeTab: Tab = "Overview";
// Track whether ProductsTab is in edit mode
let productsEditing = false;
/** Guard: block tab switch if ProductsTab has unsaved edits */
function guardedSetTab(tab: Tab) {
if (activeTab === tab) return;
if (productsEditing) {
if (!confirm('You have unsaved product changes. Discard and switch tabs?')) {
return;
}
productsEditing = false;
}
activeTab = tab;
}
// Product to auto-select when switching to Products tab
let pendingProductId: number | null = null;
function handleSelectProduct(e: CustomEvent<number>) {
pendingProductId = e.detail;
guardedSetTab("Products");
}
// Mobile nav state // Mobile nav state
let mobileActiveTab: Tab | null = null; let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) { function selectMobileTab(tab: Tab) {
if (productsEditing) {
if (!confirm('You have unsaved product changes. Discard and switch tabs?')) {
return;
}
productsEditing = false;
}
activeTab = tab; activeTab = tab;
mobileActiveTab = tab; mobileActiveTab = tab;
} }
function mobileBack() { function mobileBack() {
if (productsEditing) {
if (!confirm('You have unsaved product changes. Discard and go back?')) {
return;
}
productsEditing = false;
}
mobileActiveTab = null; mobileActiveTab = null;
} }
</script> </script>
@@ -199,7 +234,7 @@
class:active={activeTab === tab} class:active={activeTab === tab}
role="tab" role="tab"
aria-selected={activeTab === tab} aria-selected={activeTab === tab}
on:click={() => (activeTab = tab)} on:click={() => guardedSetTab(tab)}
> >
{tab} {tab}
{#if tab === "Products" && products.length > 0} {#if tab === "Products" && products.length > 0}
@@ -216,13 +251,21 @@
</div> </div>
<div class="detail-pane-body"> <div class="detail-pane-body">
{#if activeTab === "Overview"} {#if activeTab === "Overview"}
<OverviewTab {opportunity} {notes} {contacts} {products} /> <OverviewTab
{opportunity}
{notes}
{contacts}
{products}
on:selectProduct={handleSelectProduct}
/>
{:else if activeTab === "Products"} {:else if activeTab === "Products"}
<ProductsTab <ProductsTab
{products} {products}
accessToken={data.accessToken} accessToken={data.accessToken}
{opportunityId} {opportunityId}
productSequence={opportunity?.productSequence ?? null} productSequence={opportunity?.productSequence ?? null}
initialProductId={pendingProductId}
bind:isEditing={productsEditing}
/> />
{:else if activeTab === "Notes"} {:else if activeTab === "Notes"}
<NotesTab <NotesTab
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { import type {
OpportunityNote, OpportunityNote,
@@ -7,6 +8,8 @@
} from "../types"; } from "../types";
import { formatDate, formatCurrency, statusColorClass } from "../types"; import { formatDate, formatCurrency, statusColorClass } from "../types";
const dispatch = createEventDispatcher<{ selectProduct: number }>();
export let opportunity: SalesOpportunity | null; export let opportunity: SalesOpportunity | null;
export let notes: OpportunityNote[]; export let notes: OpportunityNote[];
export let contacts: OpportunityContact[]; export let contacts: OpportunityContact[];
@@ -27,7 +30,7 @@
$: totalRevenue = activeProducts.reduce((s, p) => s + (p.revenue ?? 0), 0); $: totalRevenue = activeProducts.reduce((s, p) => s + (p.revenue ?? 0), 0);
$: totalCost = activeProducts.reduce((s, p) => s + (p.cost ?? 0), 0); $: totalCost = activeProducts.reduce((s, p) => s + (p.cost ?? 0), 0);
$: totalMargin = totalRevenue - totalCost; $: totalMargin = totalRevenue - totalCost;
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0; $: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: totalTax = opportunity?.totalSalesTax ?? 0; $: totalTax = opportunity?.totalSalesTax ?? 0;
$: grandTotal = totalRevenue + totalTax; $: grandTotal = totalRevenue + totalTax;
@@ -52,9 +55,7 @@
...data, ...data,
margin: data.revenue - data.cost, margin: data.revenue - data.cost,
marginPct: marginPct:
data.revenue > 0 data.cost > 0 ? ((data.revenue - data.cost) / data.cost) * 100 : 0,
? ((data.revenue - data.cost) / data.revenue) * 100
: 0,
})); }));
})(); })();
@@ -142,6 +143,39 @@
} }
return formatCurrency(amount); return formatCurrency(amount);
} }
// ── Product popover state ──
let hoveredProduct: OpportunityProduct | null = null;
let popoverX = 0;
let popoverY = 0;
let popoverTimeout: ReturnType<typeof setTimeout> | null = null;
function showPopover(e: MouseEvent, product: OpportunityProduct) {
if (popoverTimeout) clearTimeout(popoverTimeout);
hoveredProduct = product;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const tableWrap = (e.currentTarget as HTMLElement).closest(
".ov-forecast-table-wrap",
);
const wrapRect = tableWrap?.getBoundingClientRect() ?? rect;
popoverX = rect.left - wrapRect.left;
popoverY = rect.top - wrapRect.top - 4;
}
function hidePopover() {
popoverTimeout = setTimeout(() => {
hoveredProduct = null;
}, 150);
}
function keepPopover() {
if (popoverTimeout) clearTimeout(popoverTimeout);
}
function productMarginPct(p: OpportunityProduct): string {
if (!p.cost || p.cost === 0) return "—";
return ((((p.revenue ?? 0) - p.cost) / p.cost) * 100).toFixed(1) + "%";
}
</script> </script>
<div class="overview-tab"> <div class="overview-tab">
@@ -405,6 +439,10 @@
class:ov-row-cancelled-full={p.cancellationType === "full"} class:ov-row-cancelled-full={p.cancellationType === "full"}
class:ov-row-cancelled-partial={p.cancellationType === class:ov-row-cancelled-partial={p.cancellationType ===
"partial"} "partial"}
on:mouseenter={(e) => showPopover(e, p)}
on:mouseleave={hidePopover}
on:click={() => dispatch("selectProduct", p.id)}
class="ov-forecast-row-clickable"
> >
<td class="col-product"> <td class="col-product">
<span class="ov-product-inline"> <span class="ov-product-inline">
@@ -433,14 +471,14 @@
</td> </td>
<td class="col-revenue">{formatCurrency(p.revenue)}</td> <td class="col-revenue">{formatCurrency(p.revenue)}</td>
<td class="col-margin"> <td class="col-margin">
{#if p.revenue && p.revenue > 0} {#if p.cost && p.cost > 0}
<span <span
class="ov-margin-badge {marginHealthColor( class="ov-margin-badge {marginHealthColor(
((p.revenue - (p.cost ?? 0)) / p.revenue) * 100, ((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) * 100,
)}" )}"
> >
{( {(
((p.revenue - (p.cost ?? 0)) / p.revenue) * ((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) *
100 100
).toFixed(0)}% ).toFixed(0)}%
</span> </span>
@@ -466,6 +504,105 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
<!-- Product hover popover -->
{#if hoveredProduct}
<div
class="ov-product-popover"
style="top: {popoverY}px; left: {popoverX}px;"
on:mouseenter={keepPopover}
on:mouseleave={hidePopover}
role="tooltip"
>
<div class="ov-popover-header">
<span class="ov-popover-id"
>{hoveredProduct.catalogItem?.identifier ?? "—"}</span
>
</div>
{#if hoveredProduct.productDescription}
<div class="ov-popover-desc">
{hoveredProduct.productDescription}
</div>
{/if}
{#if hoveredProduct.forecastDescription && hoveredProduct.forecastDescription !== hoveredProduct.productDescription}
<div class="ov-popover-field">
<span class="ov-popover-label">Forecast</span>
<span class="ov-popover-value"
>{hoveredProduct.forecastDescription}</span
>
</div>
{/if}
{#if hoveredProduct.forecastType}
<div class="ov-popover-field">
<span class="ov-popover-label">Type</span>
<span class="ov-popover-value"
>{hoveredProduct.forecastType}</span
>
</div>
{/if}
{#if hoveredProduct.productClass}
<div class="ov-popover-field">
<span class="ov-popover-label">Class</span>
<span class="ov-popover-value"
>{hoveredProduct.productClass}</span
>
</div>
{/if}
<div class="ov-popover-financials">
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Qty</span>
<span class="ov-popover-fin-value"
>{hoveredProduct.quantity ?? "—"}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Revenue</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.revenue)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Cost</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.cost)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.margin)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin %</span>
<span class="ov-popover-fin-value"
>{productMarginPct(hoveredProduct)}</span
>
</div>
</div>
{#if hoveredProduct.cancellationType}
<div
class="ov-popover-cancel {hoveredProduct.cancellationType ===
'full'
? 'ov-popover-cancel--full'
: ''}"
>
{hoveredProduct.cancellationType === "partial"
? "Partially Cancelled"
: "Cancelled"}
{#if hoveredProduct.quantityCancelled}
{hoveredProduct.quantityCancelled} unit{hoveredProduct.quantityCancelled !==
1
? "s"
: ""}
{/if}
</div>
{/if}
{#if hoveredProduct.recurringFlag}
<div class="ov-popover-flag">Recurring</div>
{/if}
</div>
{/if}
{#if activeProducts.length > 15} {#if activeProducts.length > 15}
<div class="ov-forecast-more"> <div class="ov-forecast-more">
+{activeProducts.length - 15} more item{activeProducts.length - +{activeProducts.length - 15} more item{activeProducts.length -
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { flip } from "svelte/animate"; import { flip } from "svelte/animate";
import { onMount, onDestroy } from "svelte";
import { beforeNavigate } from "$app/navigation";
import type { OpportunityProduct } from "../types"; import type { OpportunityProduct } from "../types";
import { formatCurrency } from "../types"; import { formatCurrency } from "../types";
import { optima } from "$lib"; import { optima } from "$lib";
@@ -12,6 +14,7 @@
export let accessToken: string | null; export let accessToken: string | null;
export let opportunityId: string; export let opportunityId: string;
export let productSequence: number[] | null = null; export let productSequence: number[] | null = null;
export let initialProductId: number | null = null;
let showAddProductModal = false; let showAddProductModal = false;
@@ -97,6 +100,118 @@
let showCancelled = false; let showCancelled = false;
let viewMode: "cards" | "compact" = "cards"; let viewMode: "cards" | "compact" = "cards";
// ── Edit mode state ──
export let isEditing = false;
let showActionMenu = false;
let editForm: {
unitPrice: string;
unitCost: string;
quantity: string;
description: string;
customerDescription: string;
productNarrative: string;
procurementNotes: string;
} = {
unitPrice: "",
unitCost: "",
quantity: "",
description: "",
customerDescription: "",
productNarrative: "",
procurementNotes: "",
};
function enterEditMode() {
if (!selectedProduct) return;
const up = unitPrice(selectedProduct);
const uc = unitCost(selectedProduct);
editForm = {
unitPrice: up != null ? up.toFixed(2) : "",
unitCost: uc != null ? uc.toFixed(2) : "",
quantity: selectedProduct.quantity?.toString() ?? "",
description: selectedProduct.productDescription ?? "",
customerDescription: selectedProduct.customerDescription ?? "",
productNarrative: selectedProduct.productNarrative ?? "",
procurementNotes: selectedProduct.procurementNotes ?? "",
};
isEditing = true;
showActionMenu = false;
}
function cancelEdit() {
isEditing = false;
}
async function saveEdit() {
if (!selectedProduct || !accessToken) return;
const qty = parseFloat(editForm.quantity) || selectedProduct.quantity || 1;
const up = parseFloat(editForm.unitPrice);
const uc = parseFloat(editForm.unitCost);
const updates: Record<string, unknown> = {
quantity: qty,
productDescription: editForm.description,
customerDescription: editForm.customerDescription || null,
productNarrative: editForm.productNarrative || null,
procurementNotes: editForm.procurementNotes || null,
};
if (!isNaN(up)) {
updates.revenue = up * qty;
}
if (!isNaN(uc)) {
updates.cost = uc * qty;
}
// TODO: Wire up the actual API call once the endpoint exists
// await optima.sales.updateProduct(accessToken, opportunityId, selectedProduct.id, updates);
console.log("[EditProduct] Would save:", { productId: selectedProduct.id, updates });
isEditing = false;
}
// ── Unsaved changes guard ──
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isEditing) {
e.preventDefault();
}
}
onMount(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
});
onDestroy(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
beforeNavigate(({ cancel }) => {
if (isEditing) {
if (!confirm('You have unsaved changes. Are you sure you want to leave?')) {
cancel();
}
}
});
function toggleActionMenu() {
showActionMenu = !showActionMenu;
}
function closeActionMenu() {
showActionMenu = false;
}
// Auto-select a product when initialProductId changes
$: if (initialProductId != null) {
const target = products.find((p) => p.id === initialProductId);
if (target) {
selectedProduct = target;
showPanel = true;
isClosing = false;
}
}
// ── Drag-and-drop state ── // ── Drag-and-drop state ──
let orderedProducts: OpportunityProduct[] = []; let orderedProducts: OpportunityProduct[] = [];
let originalOrderIds: number[] = []; let originalOrderIds: number[] = [];
@@ -197,11 +312,12 @@
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0); $: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0); $: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0);
$: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0); $: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0);
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0; $: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
function marginHealthColor(revenue?: number, margin?: number): string { function marginHealthColor(revenue?: number, margin?: number): string {
if (!revenue || revenue === 0) return "neutral"; const cost = (revenue ?? 0) - (margin ?? 0);
const pct = ((margin ?? 0) / revenue) * 100; if (!cost || cost <= 0) return "neutral";
const pct = ((margin ?? 0) / cost) * 100;
if (pct >= 30) return "healthy"; if (pct >= 30) return "healthy";
if (pct >= 15) return "moderate"; if (pct >= 15) return "moderate";
if (pct >= 0) return "low"; if (pct >= 0) return "low";
@@ -321,12 +437,26 @@
closeDetail(); closeDetail();
return; return;
} }
if (isEditing) {
if (!confirm('You have unsaved changes. Discard and switch products?')) {
return;
}
isEditing = false;
showActionMenu = false;
}
isClosing = false; isClosing = false;
selectedProduct = p; selectedProduct = p;
showPanel = true; showPanel = true;
} }
function closeDetail() { function closeDetail() {
if (isEditing) {
if (!confirm('You have unsaved changes. Discard and close?')) {
return;
}
}
isEditing = false;
showActionMenu = false;
isClosing = true; isClosing = true;
setTimeout(() => { setTimeout(() => {
selectedProduct = null; selectedProduct = null;
@@ -336,8 +466,18 @@
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && selectedProduct) { if (e.key === "Escape") {
closeDetail(); if (showActionMenu) {
showActionMenu = false;
return;
}
if (isEditing) {
cancelEdit();
return;
}
if (selectedProduct) {
closeDetail();
}
} }
} }
@@ -810,7 +950,7 @@
)}" )}"
style="width: {Math.min( style="width: {Math.min(
Math.max( Math.max(
((p.margin ?? 0) / (p.revenue || 1)) * 100, ((p.margin ?? 0) / ((p.cost ?? 0) || 1)) * 100,
0, 0,
), ),
100, 100,
@@ -1032,7 +1172,16 @@
<div class="detail-header"> <div class="detail-header">
<div class="detail-header-left"> <div class="detail-header-left">
<h3 class="detail-title"> <h3 class="detail-title">
{selectedProduct.productDescription ?? "Product"} {#if isEditing}
<textarea
class="edit-title-input"
bind:value={editForm.description}
placeholder="Product description"
rows="2"
></textarea>
{:else}
{selectedProduct.productDescription ?? "Product"}
{/if}
</h3> </h3>
<div class="detail-badges"> <div class="detail-badges">
{#if selectedProduct.productClass} {#if selectedProduct.productClass}
@@ -1046,37 +1195,82 @@
{selectedProduct.productClass} {selectedProduct.productClass}
</span> </span>
{/if} {/if}
{#if selectedProduct.status?.name}
<span
class="product-status-badge"
class:included={selectedProduct.includeFlag}
>
{selectedProduct.status.name}
</span>
{/if}
</div> </div>
</div> </div>
<button <div class="detail-header-actions">
class="detail-close" {#if isEditing}
on:click={closeDetail} <button
aria-label="Close detail" class="detail-action-btn cancel-btn"
type="button" on:click={cancelEdit}
> type="button"
<svg title="Discard changes"
viewBox="0 0 24 24" >
fill="none" Cancel
stroke="currentColor" </button>
stroke-width="2" <button
width="18" class="detail-action-btn save-btn"
height="18" on:click={saveEdit}
type="button"
title="Save changes"
>
Save
</button>
{:else}
<div class="detail-menu-wrap">
<button
class="detail-menu-btn"
on:click={toggleActionMenu}
type="button"
aria-label="Product actions"
title="Actions"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</button>
{#if showActionMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="detail-action-menu" on:click|stopPropagation>
<button
class="detail-action-menu-item"
on:click={enterEditMode}
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit Product
</button>
</div>
{/if}
</div>
{/if}
<button
class="detail-close"
on:click={closeDetail}
aria-label="Close detail"
type="button"
> >
<path d="M18 6L6 18M6 6l12 12" /> <svg
</svg> viewBox="0 0 24 24"
</button> fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div> </div>
<!-- Cancelled alert --> <!-- Cancelled alert (hidden during edit) -->
{#if selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0)} {#if !isEditing && (selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0))}
<div <div
class="detail-cancelled-card" class="detail-cancelled-card"
class:partial={selectedProduct.cancellationType === class:partial={selectedProduct.cancellationType ===
@@ -1177,9 +1371,20 @@
{/if} {/if}
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Quantity</span> <span class="detail-field-label">Quantity</span>
<span class="detail-field-value" {#if isEditing}
>{selectedProduct.quantity ?? "—"}</span <input
> class="edit-input"
type="number"
min="0"
step="1"
bind:value={editForm.quantity}
placeholder="Qty"
/>
{:else}
<span class="detail-field-value"
>{selectedProduct.quantity ?? "—"}</span
>
{/if}
</div> </div>
</div> </div>
@@ -1200,6 +1405,61 @@
</svg> </svg>
Financials Financials
</div> </div>
<!-- Unit Pricing -->
<div class="detail-unit-pricing">
<span class="detail-unit-pricing-label">Unit Pricing</span>
<div class="detail-unit-pricing-grid">
<div class="detail-field">
<span class="detail-field-label">Unit Price</span>
{#if isEditing}
<input
class="edit-input"
type="number"
min="0"
step="0.01"
bind:value={editForm.unitPrice}
placeholder="0.00"
/>
{:else}
<span class="detail-field-value"
>{formatCurrency(unitPrice(selectedProduct))}</span
>
{/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),
)}
{:else}
{formatCurrency(unitMargin(selectedProduct))}
{/if}</span
>
</div>
</div>
</div>
<div class="detail-finance-grid"> <div class="detail-finance-grid">
<div class="detail-finance-card rev"> <div class="detail-finance-card rev">
<span class="detail-finance-label">Revenue</span> <span class="detail-finance-label">Revenue</span>
@@ -1226,11 +1486,16 @@
> >
</div> </div>
</div> </div>
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">Percentage</span> <span class="detail-field-label">Margin %</span>
<span class="detail-field-value"> <span class="detail-field-value">
{selectedProduct.percentage != null {selectedProduct.cost
? `${selectedProduct.percentage}%` ? (
((selectedProduct.margin ?? 0) /
selectedProduct.cost) *
100
).toFixed(1) + "%"
: "—"} : "—"}
</span> </span>
</div> </div>
@@ -1245,7 +1510,7 @@
style="width: {Math.min( style="width: {Math.min(
Math.max( Math.max(
((selectedProduct.margin ?? 0) / ((selectedProduct.margin ?? 0) /
(selectedProduct.revenue || 1)) * ((selectedProduct.cost ?? 0) || 1)) *
100, 100,
0, 0,
), ),
@@ -1254,10 +1519,10 @@
></div> ></div>
</div> </div>
<span class="detail-margin-pct"> <span class="detail-margin-pct">
{selectedProduct.revenue {selectedProduct.cost
? ( ? (
((selectedProduct.margin ?? 0) / ((selectedProduct.margin ?? 0) /
selectedProduct.revenue) * selectedProduct.cost) *
100 100
).toFixed(1) + "% margin" ).toFixed(1) + "% margin"
: "—"} : "—"}
@@ -1265,6 +1530,72 @@
</div> </div>
</div> </div>
<!-- Section: Details (text fields) -->
<div class="detail-section">
<div class="detail-section-header">
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Details
</div>
<div class="detail-field">
<span class="detail-field-label">Customer Description</span>
{#if isEditing}
<textarea
class="edit-textarea"
bind:value={editForm.customerDescription}
placeholder="Customer-facing description…"
rows="2"
></textarea>
{:else}
<span class="detail-field-value"
>{selectedProduct.customerDescription || "—"}</span
>
{/if}
</div>
<div class="detail-field">
<span class="detail-field-label">Product Narrative</span>
{#if isEditing}
<textarea
class="edit-textarea"
bind:value={editForm.productNarrative}
placeholder="Product narrative…"
rows="3"
></textarea>
{:else}
<span class="detail-field-value"
>{selectedProduct.productNarrative || "—"}</span
>
{/if}
</div>
<div class="detail-field">
<span class="detail-field-label">Procurement Notes</span>
{#if isEditing}
<textarea
class="edit-textarea"
bind:value={editForm.procurementNotes}
placeholder="Procurement notes…"
rows="3"
></textarea>
{:else}
<span class="detail-field-value"
>{selectedProduct.procurementNotes || "—"}</span
>
{/if}
</div>
</div>
<!-- Section: Flags & Settings --> <!-- Section: Flags & Settings -->
<div class="detail-section"> <div class="detail-section">
<div class="detail-section-header"> <div class="detail-section-header">
@@ -2125,7 +2456,7 @@
} }
.card-metric-value.accent { .card-metric-value.accent {
color: var(--accent-primary, var(--accent, #60a5fa)); color: #3498db;
} }
/* Margin health bar (inline in card) */ /* Margin health bar (inline in card) */
@@ -2380,6 +2711,166 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Header actions container */
.detail-header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Three-dot menu button */
.detail-menu-wrap {
position: relative;
}
.detail-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.detail-menu-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Dropdown menu */
.detail-action-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 160px;
padding: 4px;
background: var(--bg-surface, #1c1c22);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 10;
}
.detail-action-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
text-align: left;
}
.detail-action-menu-item:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.06));
color: var(--text-primary);
}
/* Edit mode action buttons */
.detail-action-btn {
padding: 5px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.cancel-btn {
background: transparent;
color: var(--text-secondary);
}
.cancel-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.save-btn {
background: var(--accent, #6366f1);
color: #fff;
}
.save-btn:hover {
background: color-mix(in srgb, var(--accent, #6366f1) 85%, #000);
}
/* Edit inputs */
.edit-input {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 6px;
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.edit-input:focus {
border-color: var(--accent, #6366f1);
}
.edit-input[type="number"] {
font-variant-numeric: tabular-nums;
}
.edit-title-input {
width: 100%;
font-size: 13px;
font-weight: 600;
padding: 6px 8px;
line-height: 1.4;
resize: vertical;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 6px;
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
font-family: inherit;
}
.edit-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 6px;
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
line-height: 1.5;
resize: vertical;
outline: none;
transition: border-color 0.15s;
}
.edit-textarea:focus {
border-color: var(--accent, #6366f1);
}
/* Cancelled card in detail */ /* Cancelled card in detail */
.detail-cancelled-card { .detail-cancelled-card {
margin: 16px 20px 0; margin: 16px 20px 0;
@@ -2529,6 +3020,9 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
margin-top: 4px;
padding-top: 10px;
border-top: 1px dashed var(--border-subtle, rgba(255, 255, 255, 0.06));
} }
.detail-finance-card { .detail-finance-card {
@@ -2569,6 +3063,34 @@
color: var(--accent); color: var(--accent);
} }
/* Unit pricing sub-section */
.detail-unit-pricing {
margin-top: 4px;
}
.detail-unit-pricing-label {
display: block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
margin-bottom: 8px;
}
.detail-unit-pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.detail-unit-pricing-grid .detail-field {
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-hover, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.05));
}
/* Margin bar in detail */ /* Margin bar in detail */
.detail-margin-bar-wrap { .detail-margin-bar-wrap {
display: flex; display: flex;
@@ -91,6 +91,9 @@ export interface OpportunityProduct {
cwUpdatedBy?: string; cwUpdatedBy?: string;
onHand?: number | null; onHand?: number | null;
inStock?: boolean | null; inStock?: boolean | null;
productNarrative?: string | null;
customerDescription?: string | null;
procurementNotes?: string | null;
[key: string]: unknown; [key: string]: unknown;
} }
+170 -7
View File
@@ -115,18 +115,18 @@
} }
.opp-status-badge.status-won { .opp-status-badge.status-won {
background: rgba(34, 197, 94, 0.12); background: rgba(37, 99, 235, 0.12);
color: #22c55e; color: #2563eb;
} }
.opp-status-badge.status-lost { .opp-status-badge.status-lost {
background: rgba(239, 68, 68, 0.12); background: rgba(220, 38, 38, 0.12);
color: #ef4444; color: #dc2626;
} }
.opp-status-badge.status-closed { .opp-status-badge.status-closed {
background: var(--status-inactive-bg, rgba(107, 114, 128, 0.12)); background: rgba(107, 114, 128, 0.12);
color: var(--status-inactive-color, #6b7280); color: #6b7280;
} }
.opp-status-badge.status-inactive { .opp-status-badge.status-inactive {
@@ -814,7 +814,8 @@
.ov-forecast-table-wrap { .ov-forecast-table-wrap {
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: visible;
position: relative;
} }
.ov-forecast-table { .ov-forecast-table {
@@ -988,6 +989,168 @@
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
} }
/* ── Product Hover Popover ── */
.ov-product-popover {
position: absolute;
z-index: 50;
transform: translateY(-100%);
width: 280px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-subtle);
border-radius: 10px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.12),
0 2px 8px rgba(0, 0, 0, 0.06);
padding: 12px 14px;
pointer-events: auto;
animation: ov-popover-in 0.15s ease-out;
}
@keyframes ov-popover-in {
from {
opacity: 0;
transform: translateY(calc(-100% + 6px));
}
to {
opacity: 1;
transform: translateY(-100%);
}
}
.ov-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.ov-popover-id {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.ov-popover-status {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--status-active-bg, rgba(34, 197, 94, 0.12));
color: var(--status-active-color, #22c55e);
}
.ov-popover-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ov-popover-field {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 3px;
font-size: 10.5px;
}
.ov-popover-label {
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 9px;
flex-shrink: 0;
}
.ov-popover-value {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ov-popover-financials {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
}
.ov-popover-fin-item {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ov-popover-fin-label {
font-size: 9.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ov-popover-fin-value {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.ov-popover-cancel {
margin-top: 8px;
padding: 4px 8px;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
background: rgba(217, 119, 6, 0.1);
color: #d97706;
}
.ov-popover-cancel--full {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.ov-popover-flag {
display: inline-block;
margin-top: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.ov-forecast-table tbody tr {
cursor: default;
}
.ov-forecast-row-clickable {
cursor: pointer;
transition: background 0.1s ease;
}
.ov-forecast-row-clickable:active {
background: var(--nav-active-bg, rgba(0, 0, 0, 0.06));
}
/* ── Class Breakdown Bars ── */ /* ── Class Breakdown Bars ── */
.ov-class-breakdown { .ov-class-breakdown {
margin-top: 14px; margin-top: 14px;
+4 -4
View File
@@ -379,8 +379,8 @@
} }
.sales-status-badge.status-lost { .sales-status-badge.status-lost {
background: #fef3c7; background: #fee2e2;
color: #d97706; color: #dc2626;
} }
.sales-status-badge.status-inactive { .sales-status-badge.status-inactive {
@@ -389,8 +389,8 @@
} }
.sales-status-badge.status-closed { .sales-status-badge.status-closed {
background: var(--status-inactive-bg, #fee2e2); background: #f3f4f6;
color: var(--status-inactive-color, #dc2626); color: #6b7280;
} }
.sales-status-badge.status-equiv { .sales-status-badge.status-equiv {