feat: enhance opportunity detail and sales flow
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,10 +466,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape" && selectedProduct) {
|
if (e.key === "Escape") {
|
||||||
|
if (showActionMenu) {
|
||||||
|
showActionMenu = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
cancelEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedProduct) {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr?: string | null): string {
|
function formatDate(dateStr?: string | null): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
@@ -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">
|
||||||
|
{#if isEditing}
|
||||||
|
<textarea
|
||||||
|
class="edit-title-input"
|
||||||
|
bind:value={editForm.description}
|
||||||
|
placeholder="Product description"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
{selectedProduct.productDescription ?? "Product"}
|
{selectedProduct.productDescription ?? "Product"}
|
||||||
|
{/if}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="detail-badges">
|
<div class="detail-badges">
|
||||||
{#if selectedProduct.productClass}
|
{#if selectedProduct.productClass}
|
||||||
@@ -1046,16 +1195,60 @@
|
|||||||
{selectedProduct.productClass}
|
{selectedProduct.productClass}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedProduct.status?.name}
|
</div>
|
||||||
<span
|
</div>
|
||||||
class="product-status-badge"
|
<div class="detail-header-actions">
|
||||||
class:included={selectedProduct.includeFlag}
|
{#if isEditing}
|
||||||
|
<button
|
||||||
|
class="detail-action-btn cancel-btn"
|
||||||
|
on:click={cancelEdit}
|
||||||
|
type="button"
|
||||||
|
title="Discard changes"
|
||||||
>
|
>
|
||||||
{selectedProduct.status.name}
|
Cancel
|
||||||
</span>
|
</button>
|
||||||
|
<button
|
||||||
|
class="detail-action-btn save-btn"
|
||||||
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="detail-close"
|
class="detail-close"
|
||||||
on:click={closeDetail}
|
on:click={closeDetail}
|
||||||
@@ -1074,9 +1267,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
|
{#if isEditing}
|
||||||
|
<input
|
||||||
|
class="edit-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
bind:value={editForm.quantity}
|
||||||
|
placeholder="Qty"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<span class="detail-field-value"
|
<span class="detail-field-value"
|
||||||
>{selectedProduct.quantity ?? "—"}</span
|
>{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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user