feat: enhance opportunity detail and sales flow
This commit is contained in:
@@ -26,4 +26,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
out
|
||||
tailwindcss-*.log
|
||||
api-calls.jsonl
|
||||
opportunity-debug.json
|
||||
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;
|
||||
},
|
||||
|
||||
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(
|
||||
`/v1/sales/opportunities/${encodeURIComponent(identifier)}`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
|
||||
@@ -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)")}
|
||||
<slot />
|
||||
{:else}
|
||||
<SessionGuard />
|
||||
<LoadingSpinner loading={!!$navigating} />
|
||||
<div class="layout-container">
|
||||
<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 {
|
||||
if (price == null || cost == null || cost === 0) return "—";
|
||||
const margin = ((price - cost) / price) * 100;
|
||||
const margin = ((price - cost) / cost) * 100;
|
||||
return `${margin.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number>) {
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
@@ -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 @@
|
||||
</div>
|
||||
<div class="detail-pane-body">
|
||||
{#if activeTab === "Overview"}
|
||||
<OverviewTab {opportunity} {notes} {contacts} {products} />
|
||||
<OverviewTab
|
||||
{opportunity}
|
||||
{notes}
|
||||
{contacts}
|
||||
{products}
|
||||
on:selectProduct={handleSelectProduct}
|
||||
/>
|
||||
{:else if activeTab === "Products"}
|
||||
<ProductsTab
|
||||
{products}
|
||||
accessToken={data.accessToken}
|
||||
{opportunityId}
|
||||
productSequence={opportunity?.productSequence ?? null}
|
||||
initialProductId={pendingProductId}
|
||||
bind:isEditing={productsEditing}
|
||||
/>
|
||||
{:else if activeTab === "Notes"}
|
||||
<NotesTab
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
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<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>
|
||||
|
||||
<div class="overview-tab">
|
||||
@@ -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"
|
||||
>
|
||||
<td class="col-product">
|
||||
<span class="ov-product-inline">
|
||||
@@ -433,14 +471,14 @@
|
||||
</td>
|
||||
<td class="col-revenue">{formatCurrency(p.revenue)}</td>
|
||||
<td class="col-margin">
|
||||
{#if p.revenue && p.revenue > 0}
|
||||
{#if p.cost && p.cost > 0}
|
||||
<span
|
||||
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
|
||||
).toFixed(0)}%
|
||||
</span>
|
||||
@@ -466,6 +504,105 @@
|
||||
</tr>
|
||||
</tfoot>
|
||||
</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}
|
||||
<div class="ov-forecast-more">
|
||||
+{activeProducts.length - 15} more item{activeProducts.length -
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { flip } from "svelte/animate";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { beforeNavigate } from "$app/navigation";
|
||||
import type { OpportunityProduct } from "../types";
|
||||
import { formatCurrency } from "../types";
|
||||
import { optima } from "$lib";
|
||||
@@ -12,6 +14,7 @@
|
||||
export let accessToken: string | null;
|
||||
export let opportunityId: string;
|
||||
export let productSequence: number[] | null = null;
|
||||
export let initialProductId: number | null = null;
|
||||
|
||||
let showAddProductModal = false;
|
||||
|
||||
@@ -97,6 +100,118 @@
|
||||
let showCancelled = false;
|
||||
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 ──
|
||||
let orderedProducts: OpportunityProduct[] = [];
|
||||
let originalOrderIds: number[] = [];
|
||||
@@ -197,11 +312,12 @@
|
||||
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
|
||||
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0);
|
||||
$: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0);
|
||||
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0;
|
||||
$: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
|
||||
|
||||
function marginHealthColor(revenue?: number, margin?: number): string {
|
||||
if (!revenue || revenue === 0) return "neutral";
|
||||
const pct = ((margin ?? 0) / revenue) * 100;
|
||||
const cost = (revenue ?? 0) - (margin ?? 0);
|
||||
if (!cost || cost <= 0) return "neutral";
|
||||
const pct = ((margin ?? 0) / cost) * 100;
|
||||
if (pct >= 30) return "healthy";
|
||||
if (pct >= 15) return "moderate";
|
||||
if (pct >= 0) return "low";
|
||||
@@ -321,12 +437,26 @@
|
||||
closeDetail();
|
||||
return;
|
||||
}
|
||||
if (isEditing) {
|
||||
if (!confirm('You have unsaved changes. Discard and switch products?')) {
|
||||
return;
|
||||
}
|
||||
isEditing = false;
|
||||
showActionMenu = false;
|
||||
}
|
||||
isClosing = false;
|
||||
selectedProduct = p;
|
||||
showPanel = true;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
if (isEditing) {
|
||||
if (!confirm('You have unsaved changes. Discard and close?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
isEditing = false;
|
||||
showActionMenu = false;
|
||||
isClosing = true;
|
||||
setTimeout(() => {
|
||||
selectedProduct = null;
|
||||
@@ -336,8 +466,18 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && selectedProduct) {
|
||||
closeDetail();
|
||||
if (e.key === "Escape") {
|
||||
if (showActionMenu) {
|
||||
showActionMenu = false;
|
||||
return;
|
||||
}
|
||||
if (isEditing) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
if (selectedProduct) {
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +950,7 @@
|
||||
)}"
|
||||
style="width: {Math.min(
|
||||
Math.max(
|
||||
((p.margin ?? 0) / (p.revenue || 1)) * 100,
|
||||
((p.margin ?? 0) / ((p.cost ?? 0) || 1)) * 100,
|
||||
0,
|
||||
),
|
||||
100,
|
||||
@@ -1032,7 +1172,16 @@
|
||||
<div class="detail-header">
|
||||
<div class="detail-header-left">
|
||||
<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>
|
||||
<div class="detail-badges">
|
||||
{#if selectedProduct.productClass}
|
||||
@@ -1046,37 +1195,82 @@
|
||||
{selectedProduct.productClass}
|
||||
</span>
|
||||
{/if}
|
||||
{#if selectedProduct.status?.name}
|
||||
<span
|
||||
class="product-status-badge"
|
||||
class:included={selectedProduct.includeFlag}
|
||||
>
|
||||
{selectedProduct.status.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="detail-close"
|
||||
on:click={closeDetail}
|
||||
aria-label="Close detail"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
<div class="detail-header-actions">
|
||||
{#if isEditing}
|
||||
<button
|
||||
class="detail-action-btn cancel-btn"
|
||||
on:click={cancelEdit}
|
||||
type="button"
|
||||
title="Discard changes"
|
||||
>
|
||||
Cancel
|
||||
</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}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="detail-close"
|
||||
on:click={closeDetail}
|
||||
aria-label="Close detail"
|
||||
type="button"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled alert -->
|
||||
{#if selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0)}
|
||||
<!-- Cancelled alert (hidden during edit) -->
|
||||
{#if !isEditing && (selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0))}
|
||||
<div
|
||||
class="detail-cancelled-card"
|
||||
class:partial={selectedProduct.cancellationType ===
|
||||
@@ -1177,9 +1371,20 @@
|
||||
{/if}
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Quantity</span>
|
||||
<span class="detail-field-value"
|
||||
>{selectedProduct.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"
|
||||
>{selectedProduct.quantity ?? "—"}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1200,6 +1405,61 @@
|
||||
</svg>
|
||||
Financials
|
||||
</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-card rev">
|
||||
<span class="detail-finance-label">Revenue</span>
|
||||
@@ -1226,11 +1486,16 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<span class="detail-field-label">Percentage</span>
|
||||
<span class="detail-field-label">Margin %</span>
|
||||
<span class="detail-field-value">
|
||||
{selectedProduct.percentage != null
|
||||
? `${selectedProduct.percentage}%`
|
||||
{selectedProduct.cost
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.cost) *
|
||||
100
|
||||
).toFixed(1) + "%"
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1245,7 +1510,7 @@
|
||||
style="width: {Math.min(
|
||||
Math.max(
|
||||
((selectedProduct.margin ?? 0) /
|
||||
(selectedProduct.revenue || 1)) *
|
||||
((selectedProduct.cost ?? 0) || 1)) *
|
||||
100,
|
||||
0,
|
||||
),
|
||||
@@ -1254,10 +1519,10 @@
|
||||
></div>
|
||||
</div>
|
||||
<span class="detail-margin-pct">
|
||||
{selectedProduct.revenue
|
||||
{selectedProduct.cost
|
||||
? (
|
||||
((selectedProduct.margin ?? 0) /
|
||||
selectedProduct.revenue) *
|
||||
selectedProduct.cost) *
|
||||
100
|
||||
).toFixed(1) + "% margin"
|
||||
: "—"}
|
||||
@@ -1265,6 +1530,72 @@
|
||||
</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 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-header">
|
||||
@@ -2125,7 +2456,7 @@
|
||||
}
|
||||
|
||||
.card-metric-value.accent {
|
||||
color: var(--accent-primary, var(--accent, #60a5fa));
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
/* Margin health bar (inline in card) */
|
||||
@@ -2380,6 +2711,166 @@
|
||||
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 */
|
||||
.detail-cancelled-card {
|
||||
margin: 16px 20px 0;
|
||||
@@ -2529,6 +3020,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border-subtle, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.detail-finance-card {
|
||||
@@ -2569,6 +3063,34 @@
|
||||
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 */
|
||||
.detail-margin-bar-wrap {
|
||||
display: flex;
|
||||
|
||||
@@ -91,6 +91,9 @@ export interface OpportunityProduct {
|
||||
cwUpdatedBy?: string;
|
||||
onHand?: number | null;
|
||||
inStock?: boolean | null;
|
||||
productNarrative?: string | null;
|
||||
customerDescription?: string | null;
|
||||
procurementNotes?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,18 +115,18 @@
|
||||
}
|
||||
|
||||
.opp-status-badge.status-won {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.opp-status-badge.status-lost {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.opp-status-badge.status-closed {
|
||||
background: var(--status-inactive-bg, rgba(107, 114, 128, 0.12));
|
||||
color: var(--status-inactive-color, #6b7280);
|
||||
background: rgba(107, 114, 128, 0.12);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.opp-status-badge.status-inactive {
|
||||
@@ -814,7 +814,8 @@
|
||||
.ov-forecast-table-wrap {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ov-forecast-table {
|
||||
@@ -988,6 +989,168 @@
|
||||
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 ── */
|
||||
.ov-class-breakdown {
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -379,8 +379,8 @@
|
||||
}
|
||||
|
||||
.sales-status-badge.status-lost {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sales-status-badge.status-inactive {
|
||||
@@ -389,8 +389,8 @@
|
||||
}
|
||||
|
||||
.sales-status-badge.status-closed {
|
||||
background: var(--status-inactive-bg, #fee2e2);
|
||||
color: var(--status-inactive-color, #dc2626);
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sales-status-badge.status-equiv {
|
||||
|
||||
Reference in New Issue
Block a user