From c628a78b272b8f5f35f8a8c908e5f7993e3f0879 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Tue, 3 Mar 2026 19:46:12 -0600 Subject: [PATCH] feat: enhance opportunity detail and sales flow --- .gitignore | 2 + src/components/SessionGuard.svelte | 39 ++ src/lib/optima-api/modules/sales.ts | 11 +- src/routes/+layout.svelte | 2 + src/routes/api/auth/check/+server.ts | 25 + src/routes/procurement/catalog/+page.svelte | 4 +- .../sales/opportunity/[id]/+page.server.ts | 41 +- .../sales/opportunity/[id]/+page.svelte | 47 +- .../[id]/components/OverviewTab.svelte | 151 ++++- .../[id]/components/ProductsTab.svelte | 608 ++++++++++++++++-- src/routes/sales/opportunity/[id]/types.ts | 3 + src/styles/sales/opportunitydetail.css | 177 ++++- src/styles/sales/sales.css | 8 +- 13 files changed, 1030 insertions(+), 88 deletions(-) create mode 100644 src/components/SessionGuard.svelte create mode 100644 src/routes/api/auth/check/+server.ts diff --git a/.gitignore b/.gitignore index 6142ccf..057342c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ vite.config.ts.timestamp-* out tailwindcss-*.log +api-calls.jsonl +opportunity-debug.json pnpm-lock.yaml diff --git a/src/components/SessionGuard.svelte b/src/components/SessionGuard.svelte new file mode 100644 index 0000000..44553a2 --- /dev/null +++ b/src/components/SessionGuard.svelte @@ -0,0 +1,39 @@ + diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index 50b57a1..ff0a786 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -148,10 +148,19 @@ export const sales = { return response.data; }, - async fetchOne(accessToken: string, identifier: string) { + async fetchOne( + accessToken: string, + identifier: string, + include?: ("notes" | "contacts" | "products")[], + ) { + const params: Record = {}; + if (include && include.length > 0) { + params.include = include.join(","); + } const response = await api.get( `/v1/sales/opportunities/${encodeURIComponent(identifier)}`, { + params, headers: { Authorization: `Bearer ${accessToken}`, }, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a26023f..72e17b3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { navigating } from "$app/stores"; import { theme } from "$lib/theme"; import LoadingSpinner from "../components/LoadingSpinner.svelte"; + import SessionGuard from "../components/SessionGuard.svelte"; const navItems = [ { @@ -45,6 +46,7 @@ {#if $page.route.id?.startsWith("/(auth)")} {:else} +
diff --git a/src/routes/api/auth/check/+server.ts b/src/routes/api/auth/check/+server.ts new file mode 100644 index 0000000..0813f37 --- /dev/null +++ b/src/routes/api/auth/check/+server.ts @@ -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 }); +}; diff --git a/src/routes/procurement/catalog/+page.svelte b/src/routes/procurement/catalog/+page.svelte index 14b3ff4..ff6651c 100644 --- a/src/routes/procurement/catalog/+page.svelte +++ b/src/routes/procurement/catalog/+page.svelte @@ -344,10 +344,10 @@ } } - // Compute margin from price/cost + // Compute margin (markup %) from price/cost function computeMargin(price?: number, cost?: number): string { if (price == null || cost == null || cost === 0) return "—"; - const margin = ((price - cost) / price) * 100; + const margin = ((price - cost) / cost) * 100; return `${margin.toFixed(1)}%`; } diff --git a/src/routes/sales/opportunity/[id]/+page.server.ts b/src/routes/sales/opportunity/[id]/+page.server.ts index 7d4398a..a60b5f0 100644 --- a/src/routes/sales/opportunity/[id]/+page.server.ts +++ b/src/routes/sales/opportunity/[id]/+page.server.ts @@ -17,23 +17,12 @@ export const load: PageServerLoad = async ({ locals, params }) => { } try { - const [ - opportunityResult, - notesResult, - contactsResult, - productsResult, - 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: [] })), + const [result, permissions] = await Promise.all([ + optima.sales.fetchOne(accessToken, params.id, [ + "notes", + "contacts", + "products", + ]), checkPermissions(accessToken, [ "sales.opportunity.fetch", "sales.opportunity.refresh", @@ -43,15 +32,23 @@ export const load: PageServerLoad = async ({ locals, params }) => { ]), ]); - const opportunity = opportunityResult?.data ?? null; - const products = productsResult?.data ?? []; - console.log("[Products]", JSON.stringify(products, null, 2)); + const { writeFileSync } = await import("fs"); + const { resolve } = await import("path"); + 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 { opportunity, opportunityId: params.id, - notes: notesResult?.data ?? [], - contacts: contactsResult?.data ?? [], + notes, + contacts, products, accessToken, permissions, diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte index b9d3e59..9131bef 100644 --- a/src/routes/sales/opportunity/[id]/+page.svelte +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -43,15 +43,50 @@ type Tab = (typeof tabs)[number]; 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) { + pendingProductId = e.detail; + guardedSetTab("Products"); + } + // Mobile nav state let mobileActiveTab: Tab | null = null; function selectMobileTab(tab: Tab) { + if (productsEditing) { + if (!confirm('You have unsaved product changes. Discard and switch tabs?')) { + return; + } + productsEditing = false; + } activeTab = tab; mobileActiveTab = tab; } function mobileBack() { + if (productsEditing) { + if (!confirm('You have unsaved product changes. Discard and go back?')) { + return; + } + productsEditing = false; + } mobileActiveTab = null; } @@ -199,7 +234,7 @@ class:active={activeTab === tab} role="tab" aria-selected={activeTab === tab} - on:click={() => (activeTab = tab)} + on:click={() => guardedSetTab(tab)} > {tab} {#if tab === "Products" && products.length > 0} @@ -216,13 +251,21 @@
{#if activeTab === "Overview"} - + {:else if activeTab === "Products"} {:else if activeTab === "Notes"} + import { createEventDispatcher } from "svelte"; import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; import type { OpportunityNote, @@ -7,6 +8,8 @@ } from "../types"; import { formatDate, formatCurrency, statusColorClass } from "../types"; + const dispatch = createEventDispatcher<{ selectProduct: number }>(); + export let opportunity: SalesOpportunity | null; export let notes: OpportunityNote[]; export let contacts: OpportunityContact[]; @@ -27,7 +30,7 @@ $: totalRevenue = activeProducts.reduce((s, p) => s + (p.revenue ?? 0), 0); $: totalCost = activeProducts.reduce((s, p) => s + (p.cost ?? 0), 0); $: totalMargin = totalRevenue - totalCost; - $: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0; + $: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0; $: totalTax = opportunity?.totalSalesTax ?? 0; $: grandTotal = totalRevenue + totalTax; @@ -52,9 +55,7 @@ ...data, margin: data.revenue - data.cost, marginPct: - data.revenue > 0 - ? ((data.revenue - data.cost) / data.revenue) * 100 - : 0, + data.cost > 0 ? ((data.revenue - data.cost) / data.cost) * 100 : 0, })); })(); @@ -142,6 +143,39 @@ } return formatCurrency(amount); } + + // ── Product popover state ── + let hoveredProduct: OpportunityProduct | null = null; + let popoverX = 0; + let popoverY = 0; + let popoverTimeout: ReturnType | 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) + "%"; + }
@@ -405,6 +439,10 @@ class:ov-row-cancelled-full={p.cancellationType === "full"} class:ov-row-cancelled-partial={p.cancellationType === "partial"} + on:mouseenter={(e) => showPopover(e, p)} + on:mouseleave={hidePopover} + on:click={() => dispatch("selectProduct", p.id)} + class="ov-forecast-row-clickable" > @@ -433,14 +471,14 @@ {formatCurrency(p.revenue)} - {#if p.revenue && p.revenue > 0} + {#if p.cost && p.cost > 0} {( - ((p.revenue - (p.cost ?? 0)) / p.revenue) * + ((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) * 100 ).toFixed(0)}% @@ -466,6 +504,105 @@ + + {#if hoveredProduct} + + {/if} + {#if activeProducts.length > 15}
+{activeProducts.length - 15} more item{activeProducts.length - diff --git a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index 0b3a84f..13f93aa 100644 --- a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -1,5 +1,7 @@