= {};
+vi.stubGlobal("localStorage", {
+ getItem: vi.fn((key: string) => mockLocalStorage[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ mockLocalStorage[key] = value;
+ }),
+});
+
+vi.stubGlobal("document", {
+ documentElement: {
+ setAttribute: vi.fn(),
+ },
+});
+
+describe("theme store", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.keys(mockLocalStorage).forEach((k) => delete mockLocalStorage[k]);
+ mockBrowser.value = true;
+ });
+
+ it("defaults to dark theme", async () => {
+ // Re-import to get a fresh store
+ const { theme } = await import("./theme");
+
+ let value: string | undefined;
+ const unsub = theme.subscribe((v) => {
+ value = v;
+ });
+
+ expect(value).toBe("dark");
+ unsub();
+ });
+
+ it("toggle switches between dark and light", async () => {
+ const { theme } = await import("./theme");
+
+ let value: string | undefined;
+ const unsub = theme.subscribe((v) => {
+ value = v;
+ });
+
+ theme.toggle();
+ expect(value).toBe("light");
+
+ theme.toggle();
+ expect(value).toBe("dark");
+
+ unsub();
+ });
+
+ it("set updates the theme directly", async () => {
+ const { theme } = await import("./theme");
+
+ let value: string | undefined;
+ const unsub = theme.subscribe((v) => {
+ value = v;
+ });
+
+ theme.set("light");
+ expect(value).toBe("light");
+
+ theme.set("dark");
+ expect(value).toBe("dark");
+
+ unsub();
+ });
+});
diff --git a/src/routes/sales/+page.svelte b/src/routes/sales/+page.svelte
index fc2c5d1..b466dda 100644
--- a/src/routes/sales/+page.svelte
+++ b/src/routes/sales/+page.svelte
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
}
- /** Determine a color class based on the resolved type flags. */
+ /** Determine a color class based on the resolved type name + flags. */
function statusColorClass(op: SalesOpportunity): string {
- if (op.closedFlag) return "status-closed";
+ if (op.closedFlag) {
+ const t = resolvedType(op);
+ if (t?.wonFlag) return "status-won";
+ if (t?.lostFlag) return "status-lost";
+ return "status-closed";
+ }
const t = resolvedType(op);
if (!t) return "status-open";
if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive";
+ const n = t.name.toLowerCase();
+ if (n.includes("future")) return "status-future";
+ if (n.includes("new")) return "status-new";
+ if (n.includes("review")) return "status-review";
+ if (n.includes("active")) return "status-active";
return "status-open";
}
@@ -253,8 +263,43 @@
return op.company?.name || "—";
}
- function priorityLabel(op: SalesOpportunity): string {
- return op.priority?.name || "—";
+ function ratingHeatClass(name: string | undefined): string {
+ if (!name) return "heat-neutral";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "heat-hot";
+ if (n.includes("warm") || n.includes("medium")) return "heat-warm";
+ if (n.includes("cold") || n.includes("cool")) return "heat-cold";
+ return "heat-neutral";
+ }
+
+ function ratingHeatLevel(name: string | undefined): number {
+ if (!name) return 0;
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return 3;
+ if (n.includes("warm") || n.includes("medium")) return 2;
+ if (n.includes("cold") || n.includes("cool")) return 1;
+ return 0;
+ }
+
+ function heatDotColor(name: string | undefined): string {
+ if (!name) return "rgba(128,128,128,0.4)";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "#ef4444";
+ if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
+ if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
+ return "rgba(128,128,128,0.4)";
+ }
+
+ function getDotStyle(level: number, ratingName: string | undefined): string {
+ const lvl = ratingHeatLevel(ratingName);
+ const filled = level <= lvl;
+ if (!filled)
+ return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;";
+ const color = heatDotColor(ratingName);
+ let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`;
+ if (ratingHeatClass(ratingName) === "heat-hot")
+ s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
+ return s;
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
Company
Stage
Status
- Priority
+ Rating
Owner
Expected Close
Updated
@@ -441,10 +486,24 @@
{statusLabel(opp)}
-
-
- {priorityLabel(opp)}
-
+
+ {#if opp.rating?.name}
+
+
+ {#each [1, 2, 3] as level}
+
+ {/each}
+
+ {opp.rating.name}
+
+ {:else}
+ —
+ {/if}
{ownerLabel(opp)}
diff --git a/src/routes/sales/opportunities/+page.svelte b/src/routes/sales/opportunities/+page.svelte
index a195969..97d6ccc 100644
--- a/src/routes/sales/opportunities/+page.svelte
+++ b/src/routes/sales/opportunities/+page.svelte
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
}
- /** Determine a color class based on the resolved type flags. */
+ /** Determine a color class based on the resolved type name + flags. */
function statusColorClass(op: SalesOpportunity): string {
- if (op.closedFlag) return "status-closed";
+ if (op.closedFlag) {
+ const t = resolvedType(op);
+ if (t?.wonFlag) return "status-won";
+ if (t?.lostFlag) return "status-lost";
+ return "status-closed";
+ }
const t = resolvedType(op);
if (!t) return "status-open";
if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive";
+ const n = t.name.toLowerCase();
+ if (n.includes("future")) return "status-future";
+ if (n.includes("new")) return "status-new";
+ if (n.includes("review")) return "status-review";
+ if (n.includes("active")) return "status-active";
return "status-open";
}
@@ -253,8 +263,43 @@
return op.company?.name || "—";
}
- function priorityLabel(op: SalesOpportunity): string {
- return op.priority?.name || "—";
+ function ratingHeatClass(name: string | undefined): string {
+ if (!name) return "heat-neutral";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "heat-hot";
+ if (n.includes("warm") || n.includes("medium")) return "heat-warm";
+ if (n.includes("cold") || n.includes("cool")) return "heat-cold";
+ return "heat-neutral";
+ }
+
+ function ratingHeatLevel(name: string | undefined): number {
+ if (!name) return 0;
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return 3;
+ if (n.includes("warm") || n.includes("medium")) return 2;
+ if (n.includes("cold") || n.includes("cool")) return 1;
+ return 0;
+ }
+
+ function heatDotColor(name: string | undefined): string {
+ if (!name) return "rgba(128,128,128,0.4)";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "#ef4444";
+ if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
+ if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
+ return "rgba(128,128,128,0.4)";
+ }
+
+ function getDotStyle(level: number, ratingName: string | undefined): string {
+ const lvl = ratingHeatLevel(ratingName);
+ const filled = level <= lvl;
+ if (!filled)
+ return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;";
+ const color = heatDotColor(ratingName);
+ let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`;
+ if (ratingHeatClass(ratingName) === "heat-hot")
+ s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
+ return s;
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
Company
Stage
Status
- Priority
+ Rating
Owner
Expected Close
Updated
@@ -441,10 +486,24 @@
{statusLabel(opp)}
-
-
- {priorityLabel(opp)}
-
+
+ {#if opp.rating?.name}
+
+
+ {#each [1, 2, 3] as level}
+
+ {/each}
+
+ {opp.rating.name}
+
+ {:else}
+ —
+ {/if}
{ownerLabel(opp)}
diff --git a/src/routes/sales/opportunity/[id]/+page.server.ts b/src/routes/sales/opportunity/[id]/+page.server.ts
index a60b5f0..d39966f 100644
--- a/src/routes/sales/opportunity/[id]/+page.server.ts
+++ b/src/routes/sales/opportunity/[id]/+page.server.ts
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes: [],
contacts: [],
products: [],
+ quotes: [],
accessToken: null,
permissions: {} as PermissionMap,
};
@@ -22,6 +23,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"notes",
"contacts",
"products",
+ "quotes",
]),
checkPermissions(accessToken, [
"sales.opportunity.fetch",
@@ -29,6 +31,11 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.note.create",
"sales.opportunity.note.update",
"sales.opportunity.note.delete",
+ "sales.opportunity.quote.fetch",
+ "sales.opportunity.quote.commit",
+ "sales.opportunity.quote.preview",
+ "sales.opportunity.quote.download",
+ "sales.opportunity.quote.fetch_downloads",
]),
]);
@@ -43,6 +50,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
const notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? [];
+ const quotes = result?.data?.quotes ?? [];
return {
opportunity,
@@ -50,6 +58,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes,
contacts,
products,
+ quotes,
accessToken,
permissions,
};
diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte
index d6b2d6d..8139920 100644
--- a/src/routes/sales/opportunity/[id]/+page.svelte
+++ b/src/routes/sales/opportunity/[id]/+page.svelte
@@ -11,6 +11,7 @@
import ContactsTab from "./components/ContactsTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
import ProductsTab from "./components/ProductsTab.svelte";
+ import QuotesTab from "./components/QuotesTab.svelte";
export let data: PageData;
@@ -19,6 +20,7 @@
$: notes = data.notes;
$: contacts = data.contacts;
$: products = data.products;
+ $: quotes = data.quotes ?? [];
$: permissions = data.permissions;
let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null;
@@ -48,6 +50,7 @@
const tabs = [
"Overview",
"Products",
+ "Quotes",
"Notes",
"Contacts",
"Activity",
@@ -55,6 +58,11 @@
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
+ // Hide Quotes tab if user lacks fetch permission
+ $: visibleTabs = tabs.filter(
+ (t) => t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false
+ );
+
// Track whether ProductsTab is in edit mode
let productsEditing = false;
@@ -126,7 +134,7 @@
{#if isMobile && mobileActiveTab === null}
@@ -293,6 +323,13 @@
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
/>
+ {:else if activeTab === "Quotes"}
+
{:else if activeTab === "Notes"}
import { goto } from "$app/navigation";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
- import { statusColorClass } from "../types";
+ import {
+ statusColorClass,
+ statusLabel,
+ isEquivalencyStatus,
+ originalStatusName,
+ formatDate,
+ } from "../types";
- import { formatDate } from "../types";
+ // Days until expected close
+ $: isClosedOpportunity = (() => {
+ if (!opportunity) return false;
+ const statusText =
+ `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
+ return (
+ !!opportunity.closedFlag ||
+ !!opportunity.closedDate ||
+ statusText.includes("won") ||
+ statusText.includes("lost")
+ );
+ })();
+
+ $: daysUntilClose = (() => {
+ if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
+ return Math.ceil(
+ (new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
+ (1000 * 60 * 60 * 24),
+ );
+ })();
export let opportunity: SalesOpportunity | null;
export let isMobile: boolean;
@@ -44,6 +69,53 @@
if (typeof closedBy === "string") return closedBy;
return closedBy.name ?? closedBy.identifier ?? String(closedBy.id ?? "");
}
+
+ /** Map rating name to a heat tier for visual styling */
+ function ratingHeatClass(name: string | undefined): string {
+ if (!name) return "heat-neutral";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "heat-hot";
+ if (n.includes("warm") || n.includes("medium")) return "heat-warm";
+ if (n.includes("cold") || n.includes("cool")) return "heat-cold";
+ return "heat-neutral";
+ }
+
+ /** Map rating name to a thermometer icon fill level (0-3) */
+ function ratingHeatLevel(name: string | undefined): number {
+ if (!name) return 0;
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return 3;
+ if (n.includes("warm") || n.includes("medium")) return 2;
+ if (n.includes("cold") || n.includes("cool")) return 1;
+ return 0;
+ }
+ /** Get the filled dot color for a rating heat tier */
+ function heatDotColor(name: string | undefined): string {
+ if (!name) return "rgba(128,128,128,0.4)";
+ const n = name.toLowerCase();
+ if (n.includes("hot")) return "#ef4444";
+ if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
+ if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
+ return "rgba(128,128,128,0.4)";
+ }
+ /** Build the complete inline style for a heat dot */
+ function getDotStyle(level: number, ratingName: string | undefined): string {
+ const lvl = ratingHeatLevel(ratingName);
+ const filled = level <= lvl;
+ if (!filled)
+ return "background: rgba(128,128,128,0.25); width: 8px; height: 8px; border-radius: 50%; display: inline-block;";
+ const color = heatDotColor(ratingName);
+ const heat = ratingHeatClass(ratingName);
+ let s = `background: ${color}; width: 8px; height: 8px; border-radius: 50%; display: inline-block;`;
+ if (heat === "heat-hot") s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
+ return s;
+ }
+ /** Map probability percent to a tier for styling */
+ function probabilityTier(percent: number): string {
+ if (percent >= 75) return "prob-high";
+ if (percent >= 40) return "prob-mid";
+ return "prob-low";
+ }
#{opportunity.cwOpportunityId}
{/if}
{#if opportunity.status}
-
- {opportunity.closedFlag ? "Closed" : opportunity.status.name}
+
+ {statusLabel(opportunity)}
{/if}
{#if opportunity.type?.name}
{opportunity.type.name}
{/if}
+ {#if opportunity.type?.wonFlag || opportunity.type?.lostFlag}
+
+ {opportunity.type.wonFlag ? "Won" : "Lost"}
+
+ {/if}
+ {#if opportunity.rating?.name}
+
+
+ {#each [1, 2, 3] as level}
+
+ {/each}
+
+ {opportunity.rating.name}
+
+ {/if}
+ {#if opportunity.probability?.percent != null}
+
+
+
+
+
+ {opportunity.probability.percent}%
+
+ {/if}
+
+ {#if daysUntilClose !== null}
+ = 0 && daysUntilClose <= 14}
+ >
+ {Math.abs(daysUntilClose)}
+
+ {daysUntilClose < 0
+ ? `day${Math.abs(daysUntilClose) !== 1 ? 's' : ''} overdue`
+ : `day${daysUntilClose !== 1 ? 's' : ''} to close`}
+
+
+ {/if}
+
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
diff --git a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte
index b570a68..272ed4c 100644
--- a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte
+++ b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte
@@ -110,7 +110,8 @@
$: isClosedOpportunity = (() => {
if (!opportunity) return false;
- const statusText = `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
+ const statusText =
+ `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
return (
!!opportunity.closedFlag ||
!!opportunity.closedDate ||
@@ -129,16 +130,6 @@
return diff;
})();
- $: closeOutcomeLabel = (() => {
- const opp = opportunity;
- if (!isClosedOpportunity || !opp) return null;
- const outcomeText =
- `${opp.status?.name ?? ""} ${opp.type?.name ?? ""}`.toLowerCase();
- if (outcomeText.includes("won")) return "WON";
- if (outcomeText.includes("lost")) return "LOST";
- return "CLOSED";
- })();
-
// Age in days
$: ageDays = (() => {
if (!opportunity?.createdAt) return null;
@@ -202,81 +193,7 @@
-
- {#if opportunity?.stage?.name}
-
-
-
-
- {opportunity.stage.name}
-
- {/if}
- {#if opportunity?.priority?.name}
-
-
-
-
- {opportunity.priority.name}
-
- {/if}
- {#if opportunity?.rating?.name}
-
-
-
-
- {opportunity.rating.name}
-
- {/if}
-
- {#if closeOutcomeLabel || daysUntilClose !== null}
-
= 0 &&
- daysUntilClose <= 14}
- >
- {#if closeOutcomeLabel}
- {closeOutcomeLabel}
- {#if closeOutcomeLabel !== "WON" && closeOutcomeLabel !== "LOST"}
- Closed opportunity
- {/if}
- {:else if daysUntilClose !== null}
- {Math.abs(daysUntilClose)}
-
- {daysUntilClose < 0
- ? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
- : `day${daysUntilClose !== 1 ? "s" : ""} to close`}
-
- {/if}
-
- {/if}
+
diff --git a/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte b/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte
new file mode 100644
index 0000000..c3eb577
--- /dev/null
+++ b/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte
@@ -0,0 +1,1275 @@
+
+
+
+
+
+
+ {#if showQuotesSubTab}
+
+
+
+
+
+ Quotes
+ {quotes.length}
+
+ {/if}
+
+
+
+
+
+ Live Preview
+
+ {#if canFetchDownloads}
+
+
+
+
+
+
+
+
+ Logs
+
+ {/if}
+
+
+
+
+ {#if viewMode === "list"}
+
+
+
+
+
+
+ {#if selectedQuoteDetail}
+
+
+
+
+
+
+
+ {#if !detailLoading && allProducts.length > 0}
+
+
+ Items
+ {totalItems}
+
+
+ Revenue
+ {formatCurrency(totalRevenue)}
+
+
+ Cost
+ {formatCurrency(totalCost)}
+
+
+ Margin
+ = 0}
+ class:negative={totalMargin < 0}
+ >
+ {formatCurrency(totalMargin)}
+
+
+
+ {/if}
+
+
+
+
+
+ {#if detailLoading}
+
+ Loading line items...
+
+ {:else if activeProducts.length > 0}
+ {#each activeProducts as product, i}
+
+ {i + 1}
+
+ {product.productDescription ?? "—"}
+ {#if product.productClass}
+ {product.productClass}
+ {/if}
+ {#if product.cancellationType === "partial"}
+
+ {product.quantityCancelled ?? 0} cancelled
+
+ {/if}
+
+ {product.effectiveQuantity ??
+ product.quantity ??
+ 0}
+ {formatCurrency(product.revenue)}
+
+ {/each}
+ {:else if allProducts.length === 0}
+
No line items
+ {:else}
+
+ All items cancelled
+
+ {/if}
+
+
+ {#if cancelledProducts.length > 0}
+
+
(cancelledExpanded = !cancelledExpanded)}
+ on:keydown={(e) => {
+ if (e.key === "Enter" || e.key === " ")
+ cancelledExpanded = !cancelledExpanded;
+ }}
+ role="button"
+ tabindex="0"
+ >
+
+
+
+
Cancelled ({cancelledProducts.length})
+
+ {#if cancelledExpanded}
+ {#each cancelledProducts as product, i}
+
+ {activeProducts.length + i + 1}
+
+ {product.productDescription ?? "—"}
+ {#if product.productClass}
+ {product.productClass}
+ {/if}
+
+ {product.quantity ?? 0}
+ {formatCurrency(product.revenue)}
+
+ {/each}
+ {/if}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if quotePreviewLoading}
+
+
+
+ Loading preview...
+
+
+
+ {:else if quotePreviewError}
+
+ {:else if quotePreviewObjectUrl}
+
+ {:else}
+
+ {/if}
+
+
+ {:else}
+
+
Select a quote from the list to view details.
+
+ {/if}
+
+
+
+
+ {:else if viewMode === "preview"}
+
+
+
+
+ {#if effectivePdfUrl}
+
+ {:else}
+
+
+
Quote PDF Preview
+
+ {#if connectionError}
+ Live preview error: {connectionError}
+ {:else if !accessToken || !opportunityId}
+ Missing authentication or opportunity id for live preview.
+ {:else if !isConnected}
+ Connecting to live preview channel...
+ {:else}
+ Connected. Waiting for live quote preview PDF payload.
+ {/if}
+
+
+
+ {/if}
+
+
+ {:else if viewMode === "logs"}
+
+
+
+
+ {#if logsLoading}
+
+ {:else if logsError}
+
+ {:else if downloadLogs.length === 0}
+
+
No download or print activity yet.
+
+ {:else}
+
+ {#each downloadLogs as log}
+
+
+ {#if log.downloads.length > 0}
+
+ {#each log.downloads as dl}
+
+
+ {#if dl.fetchAction === "download"}
+
+
+
+
+
+ {:else}
+
+
+
+
+
+ {/if}
+ {dl.fetchAction}
+
+
{dl.userName}
+
{formatDate(dl.downloadedAt)}
+
+ {/each}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
diff --git a/src/routes/sales/opportunity/[id]/types.ts b/src/routes/sales/opportunity/[id]/types.ts
index c18a2cb..ff3333d 100644
--- a/src/routes/sales/opportunity/[id]/types.ts
+++ b/src/routes/sales/opportunity/[id]/types.ts
@@ -148,12 +148,93 @@ export function formatCurrency(amount?: number | null): string {
}).format(amount);
}
+/**
+ * Canonical status IDs → pipeline tier.
+ * Equivalency IDs (legacy/pre-2024) are mapped to the same tier as their canonical parent.
+ */
+const STATUS_TIER: Record
= (() => {
+ const map: Record = {};
+ // FutureLead (id 51) + equivalencies
+ for (const id of [51, 35, 36]) map[id] = "status-future";
+ // New (id 24) + equivalencies
+ for (const id of [24, 1, 13, 37]) map[id] = "status-new";
+ // Internal Review (id 56) + equivalencies
+ for (const id of [56, 10, 26, 27, 28, 41, 54]) map[id] = "status-review";
+ // Active (id 58) + equivalencies
+ for (const id of [
+ 58, 9, 15, 16, 17, 18, 19, 20, 25, 43, 38, 39, 40, 42, 44, 45, 46, 47, 48,
+ 52, 55, 57,
+ ])
+ map[id] = "status-active";
+ // Won (id 29) + equivalencies
+ for (const id of [29, 2, 49]) map[id] = "status-won";
+ // Lost (id 53) + equivalencies
+ for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34, 50])
+ map[id] = "status-lost";
+ return map;
+})();
+
+/** Canonical display name for each tier */
+const CANONICAL_NAMES: Record = {
+ 51: "FutureLead",
+ 24: "New",
+ 56: "Internal Review",
+ 58: "Active",
+ 29: "Won",
+ 53: "Lost",
+};
+
+/** IDs that are canonical (not equivalency-mapped) */
+const CANONICAL_IDS = new Set([51, 24, 56, 58, 29, 53]);
+
export function statusColorClass(opportunity: SalesOpportunity): string {
- if (opportunity.closedFlag) return "status-closed";
- const name = opportunity.status?.name?.toLowerCase();
- if (!name) return "status-open";
- if (name === "won") return "status-won";
- if (name === "lost") return "status-lost";
- if (name === "inactive") return "status-inactive";
+ if (opportunity.closedFlag) {
+ const sid = opportunity.status?.id;
+ if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
+ return "status-closed";
+ }
+ const sid = opportunity.status?.id;
+ if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-open";
}
+
+/** Get the canonical display label for the status (resolves equivalencies). */
+export function statusLabel(opportunity: SalesOpportunity): string {
+ if (opportunity.closedFlag) {
+ const sid = opportunity.status?.id;
+ if (sid != null) {
+ for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
+ const tier = STATUS_TIER[sid];
+ const canonTier = STATUS_TIER[Number(canonId)];
+ if (tier && tier === canonTier) return name;
+ }
+ }
+ return "Closed";
+ }
+ const sid = opportunity.status?.id;
+ if (sid != null) {
+ // If it IS the canonical ID, just use its name
+ if (CANONICAL_IDS.has(sid))
+ return CANONICAL_NAMES[sid] ?? opportunity.status?.name ?? "Open";
+ // Otherwise it's an equivalency — return the canonical name
+ const tier = STATUS_TIER[sid];
+ if (tier) {
+ for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
+ if (STATUS_TIER[Number(canonId)] === tier) return name;
+ }
+ }
+ }
+ return opportunity.status?.name ?? "Open";
+}
+
+/** Whether this status is an equivalency-mapped status (not canonical). */
+export function isEquivalencyStatus(opportunity: SalesOpportunity): boolean {
+ const sid = opportunity.status?.id;
+ if (sid == null) return false;
+ return STATUS_TIER[sid] != null && !CANONICAL_IDS.has(sid);
+}
+
+/** The original CW status name (for tooltip on equivalency statuses). */
+export function originalStatusName(opportunity: SalesOpportunity): string {
+ return opportunity.status?.name ?? "Unknown";
+}
diff --git a/src/styles/sales/opportunitydetail.css b/src/styles/sales/opportunitydetail.css
index 0e43949..6abceab 100644
--- a/src/styles/sales/opportunitydetail.css
+++ b/src/styles/sales/opportunitydetail.css
@@ -92,6 +92,42 @@
margin-top: 8px;
}
+/* ── Days to close countdown (sidebar) ── */
+.opp-close-countdown {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ margin-top: 10px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ background: rgba(34, 197, 94, 0.08);
+ color: #16a34a;
+}
+
+.opp-close-countdown.soon {
+ background: rgba(245, 158, 11, 0.10);
+ color: #d97706;
+}
+
+.opp-close-countdown.overdue {
+ background: rgba(239, 68, 68, 0.10);
+ color: #dc2626;
+}
+
+.opp-close-number {
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
+ font-size: 18px;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.opp-close-unit {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
.opp-number {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 12px;
@@ -109,9 +145,33 @@
letter-spacing: 0.5px;
}
+/* ── FutureLead: muted purple ── */
+.opp-status-badge.status-future {
+ background: rgba(139, 92, 246, 0.12);
+ color: #7c3aed;
+}
+
+/* ── New: teal ── */
+.opp-status-badge.status-new {
+ background: rgba(20, 184, 166, 0.12);
+ color: #0d9488;
+}
+
+/* ── Internal Review: amber ── */
+.opp-status-badge.status-review {
+ background: rgba(245, 158, 11, 0.12);
+ color: #d97706;
+}
+
+/* ── Active: green ── */
+.opp-status-badge.status-active {
+ background: rgba(34, 197, 94, 0.12);
+ color: #16a34a;
+}
+
.opp-status-badge.status-open {
- background: var(--status-active-bg, rgba(34, 197, 94, 0.12));
- color: var(--status-active-color, #22c55e);
+ background: rgba(34, 197, 94, 0.12);
+ color: #16a34a;
}
.opp-status-badge.status-won {
@@ -134,6 +194,38 @@
color: #6b7280;
}
+/* ── Equivalency dashed-border + tooltip ── */
+.opp-status-badge.status-equiv {
+ border: 1px dashed currentColor;
+ cursor: default;
+ position: relative;
+}
+
+.opp-status-badge.status-equiv[data-tooltip]::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--tooltip-bg, #1e293b);
+ color: var(--tooltip-color, #f8fafc);
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: none;
+ letter-spacing: 0;
+ white-space: nowrap;
+ padding: 4px 8px;
+ border-radius: 5px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 10;
+}
+
+.opp-status-badge.status-equiv[data-tooltip]:hover::after {
+ opacity: 1;
+}
+
.opp-stage-badge {
display: inline-flex;
padding: 2px 8px;
@@ -154,6 +246,139 @@
color: var(--text-secondary);
}
+.opp-outcome-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.opp-outcome-badge.outcome-won {
+ background: rgba(37, 99, 235, 0.12);
+ color: #2563eb;
+}
+
+.opp-outcome-badge.outcome-lost {
+ background: rgba(220, 38, 38, 0.12);
+ color: #dc2626;
+}
+
+.opp-rating-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 9px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ transition: all 0.2s;
+}
+
+/* Heat dots */
+.opp-heat-dots {
+ display: inline-flex;
+ gap: 2px;
+ align-items: center;
+}
+
+.opp-heat-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ transition: all 0.2s;
+}
+
+/* ── Hot ── */
+.opp-rating-badge.heat-hot {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.25);
+ animation: hotPulse 2s ease-in-out infinite;
+}
+
+.opp-rating-badge.heat-hot .opp-heat-dot.filled {
+ background: #ef4444;
+ box-shadow: 0 0 4px rgba(239, 68, 68, 0.6);
+}
+
+@keyframes hotPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.2);
+ }
+ 50% {
+ box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
+ }
+}
+
+/* ── Warm ── */
+.opp-rating-badge.heat-warm {
+ background: rgba(245, 158, 11, 0.14);
+ color: #d97706;
+}
+
+.opp-rating-badge.heat-warm .opp-heat-dot.filled {
+ background: #f59e0b;
+}
+
+/* ── Cold ── */
+.opp-rating-badge.heat-cold {
+ background: rgba(56, 189, 248, 0.12);
+ color: #0ea5e9;
+}
+
+.opp-rating-badge.heat-cold .opp-heat-dot.filled {
+ background: #38bdf8;
+}
+
+/* ── Neutral ── */
+.opp-rating-badge.heat-neutral {
+ background: var(--nav-hover-bg);
+ color: var(--text-secondary);
+}
+
+.opp-rating-badge.heat-neutral .opp-heat-dot.filled {
+ background: var(--text-muted);
+}
+
+/* ── Probability badge ── */
+.opp-probability-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
+}
+
+.opp-prob-ring {
+ flex-shrink: 0;
+}
+
+.opp-probability-badge.prob-high {
+ background: rgba(34, 197, 94, 0.12);
+ color: #16a34a;
+}
+
+.opp-probability-badge.prob-mid {
+ background: rgba(245, 158, 11, 0.12);
+ color: #d97706;
+}
+
+.opp-probability-badge.prob-low {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
/* ── Byline (Sales Rep) ── */
.opp-byline {
display: flex;
@@ -469,6 +694,8 @@
flex: 1;
overflow-y: auto;
padding: 24px;
+ display: flex;
+ flex-direction: column;
}
/* ═══════════════════════════════════════════════════
@@ -478,7 +705,7 @@
.overview-tab {
display: flex;
flex-direction: column;
- gap: 24px;
+ gap: 16px;
}
/* ── Pipeline Banner ── */
@@ -1882,6 +2109,1065 @@
gap: 24px;
}
+/* ── Quotes tab ── */
+.quotes-tab {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+/* ── View toggle bar ── */
+.quotes-view-bar {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ flex-shrink: 0;
+}
+
+.quotes-view-tabs {
+ display: flex;
+ gap: 2px;
+ background: var(--nav-hover-bg);
+ border-radius: 8px;
+ padding: 3px;
+}
+
+.quotes-view-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+}
+
+.quotes-view-tab:hover {
+ color: var(--text-primary);
+}
+
+.quotes-view-tab.active {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+.quotes-view-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ font-size: 10px;
+ font-weight: 700;
+ background: var(--input-focus-border);
+ color: #fff;
+}
+
+/* ── List view layout ── */
+.quotes-list-layout {
+ display: grid;
+ grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
+ grid-template-rows: 1fr;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+}
+
+.quotes-list-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow-y: auto;
+ padding: 14px 16px;
+ background: var(--nav-hover-bg);
+ border-radius: 10px;
+ min-height: 0;
+}
+
+.quotes-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.quotes-new-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ color 0.15s ease,
+ background 0.15s ease;
+}
+
+.quotes-new-btn:hover {
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+ background: color-mix(in srgb, var(--bg-surface) 88%, var(--accent-primary));
+}
+
+.quotes-list-items {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.quotes-list-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease;
+}
+
+.quotes-list-item:hover {
+ background: var(--bg-surface);
+}
+
+.quotes-list-item.active {
+ background: var(--bg-surface);
+ border-color: var(--input-focus-border);
+}
+
+.quotes-list-item-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.quotes-list-item-date {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.quotes-list-empty {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 8px 0;
+}
+
+.quotes-list-error {
+ color: #ef4444;
+}
+
+.quotes-list-empty-link {
+ display: inline;
+ background: none;
+ border: none;
+ color: var(--accent-primary);
+ font-size: 12px;
+ font-weight: 600;
+ padding: 0;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+/* ── Detail area ── */
+.quotes-detail-area {
+ display: flex;
+ flex-direction: column;
+ background: var(--nav-hover-bg);
+ border-radius: 10px;
+ overflow: hidden;
+ min-height: 0;
+}
+
+.quotes-detail-content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(180px, 280px);
+ gap: 16px;
+ flex: 1;
+ padding: 16px;
+ min-height: 0;
+}
+
+.quotes-detail-info {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Compact header ── */
+.quotes-detail-header {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.quotes-detail-header-top {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+.quotes-detail-title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.quotes-detail-header-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.quotes-detail-date {
+ font-size: 12px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.quotes-detail-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.quotes-action-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.quotes-action-btn:hover {
+ background: var(--nav-hover-bg);
+ color: var(--text-primary);
+ border-color: var(--text-muted);
+}
+
+.quotes-action-btn:active {
+ transform: scale(0.93);
+}
+
+.quotes-action-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.quotes-detail-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.quotes-detail-meta-loading {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* ── Line items table ── */
+.quotes-line-items {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ margin-top: 12px;
+}
+
+.quotes-line-items-header {
+ display: flex;
+ align-items: center;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.quotes-line-items-header .quotes-li-col {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+}
+
+.quotes-line-items-body {
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.quotes-line-item {
+ display: flex;
+ align-items: center;
+ padding: 7px 8px;
+ border-bottom: 1px solid
+ color-mix(in srgb, var(--border-subtle) 50%, transparent);
+ transition: background 0.1s;
+}
+
+.quotes-line-item:hover {
+ background: var(--nav-hover-bg);
+}
+
+.quotes-line-item.cancelled {
+ opacity: 0.45;
+ text-decoration: line-through;
+}
+
+.quotes-line-item.partial-cancelled {
+ opacity: 0.7;
+}
+
+.quotes-li-partial-tag {
+ display: inline;
+ margin-left: 6px;
+ font-size: 10px;
+ color: var(--warning, #e6a817);
+ font-weight: 600;
+}
+
+/* ── Summary bar ── */
+.quotes-summary-bar {
+ display: flex;
+ gap: 12px;
+ padding: 8px 10px;
+ background: var(--nav-hover-bg);
+ border-radius: 6px;
+ margin-bottom: 8px;
+ flex-shrink: 0;
+}
+
+.quotes-summary-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+}
+
+.quotes-summary-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.quotes-summary-value {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.quotes-summary-value.positive {
+ color: var(--success, #22c55e);
+}
+
+.quotes-summary-value.negative {
+ color: var(--error, #ef4444);
+}
+
+/* ── Cancelled accordion ── */
+.quotes-cancelled-accordion {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ margin-top: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-top: 1px solid var(--border-subtle);
+ user-select: none;
+ transition: color 0.15s;
+}
+
+.quotes-cancelled-accordion:hover {
+ color: var(--text-primary);
+}
+
+.quotes-accordion-chevron {
+ transition: transform 0.2s ease;
+ flex-shrink: 0;
+}
+
+.quotes-accordion-chevron.expanded {
+ transform: rotate(90deg);
+}
+
+.quotes-li-col {
+ font-size: 12px;
+ color: var(--text-primary);
+}
+
+.quotes-li-num {
+ width: 28px;
+ flex-shrink: 0;
+ color: var(--text-muted);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+.quotes-li-desc {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.quotes-li-class {
+ display: inline;
+ margin-left: 6px;
+ font-size: 10px;
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.quotes-li-qty {
+ width: 40px;
+ flex-shrink: 0;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.quotes-li-price {
+ width: 90px;
+ flex-shrink: 0;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ font-weight: 500;
+}
+
+.quotes-line-items-empty {
+ padding: 20px 8px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+/* ── Quote ID footer ── */
+.quotes-detail-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 8px;
+ flex-shrink: 0;
+}
+
+.quotes-detail-quote-id {
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
+ font-size: 10px;
+ color: color-mix(in srgb, var(--text-muted) 40%, transparent);
+ user-select: all;
+ letter-spacing: 0.2px;
+}
+
+.quotes-detail-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.quotes-detail-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.quotes-field-mono {
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
+ font-size: 11px;
+ word-break: break-all;
+}
+
+.quotes-detail-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.quotes-detail-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 500;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ color: var(--text-muted);
+}
+
+.quotes-detail-badge.active {
+ border-color: var(--input-focus-border);
+ color: var(--accent-primary);
+ background: color-mix(in srgb, var(--bg-surface) 90%, var(--accent-primary));
+}
+
+/* ── Regen params display ── */
+.quotes-detail-params {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.quotes-detail-param {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.quotes-param-key {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+/* ── PDF preview in detail view ── */
+.quotes-detail-preview {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ padding: 4px 0;
+}
+
+.quotes-detail-preview-frame {
+ width: 100%;
+ flex: 1;
+ min-height: 280px;
+ border-radius: 8px;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+
+.quotes-preview-loading,
+.quotes-preview-error-frame {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.quotes-pdf-page-mini {
+ min-height: 280px;
+ aspect-ratio: 8.5 / 11;
+ padding: 20px;
+}
+
+.quotes-pdf-page-mini h4 {
+ font-size: 12px;
+}
+
+.quotes-pdf-page-subtitle {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.quotes-detail-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ padding: 40px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+/* ── Back to list button ── */
+.quotes-back-to-list {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 10px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ color 0.15s ease,
+ background 0.15s ease;
+}
+
+.quotes-back-to-list:hover {
+ color: var(--text-primary);
+ background: var(--bg-surface);
+}
+
+.quotes-sidebar-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 2px 0 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+
+.quotes-sidebar-header svg {
+ color: var(--accent-primary);
+ flex-shrink: 0;
+}
+
+.quotes-sidebar-divider {
+ height: 1px;
+ background: var(--border-subtle);
+ margin: 10px 0;
+}
+
+.quotes-create-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ padding: 9px 10px;
+ border: 1px dashed var(--border-subtle);
+ border-radius: 8px;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ margin-top: 12px;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease,
+ color 0.15s ease;
+}
+
+.quotes-create-btn:hover:not(:disabled) {
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+ background: color-mix(in srgb, var(--bg-surface) 88%, var(--accent-primary));
+}
+
+.quotes-create-btn:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.quotes-create-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.quotes-commit-msg {
+ margin-top: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+.quotes-commit-error {
+ background: color-mix(in srgb, var(--bg-surface) 85%, #ef4444);
+ color: #ef4444;
+ border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, #ef4444);
+}
+
+.quotes-commit-success {
+ background: color-mix(in srgb, var(--bg-surface) 85%, #22c55e);
+ color: #22c55e;
+ border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, #22c55e);
+}
+
+.quotes-fields-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+}
+
+.quotes-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: var(--bg-surface);
+}
+
+.quotes-field-label {
+ font-size: 11px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.quotes-field-value {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.quotes-flags-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+}
+
+.quotes-flag-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ font-size: 13px;
+ color: var(--text-primary);
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease;
+ cursor: pointer;
+ user-select: none;
+}
+
+.quotes-flag-row:hover {
+ border-color: var(--input-focus-border);
+ background: color-mix(in srgb, var(--bg-surface) 88%, var(--accent-primary));
+}
+
+.quotes-flag-row input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ border-radius: 4px;
+ accent-color: var(--accent-primary);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.quotes-flag-count {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.quotes-empty {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.quotes-preview-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.quotes-preview-layout {
+ display: grid;
+ grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
+ grid-template-rows: 1fr;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+}
+
+.quotes-preview-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow-y: auto;
+ padding: 14px 16px;
+ background: var(--nav-hover-bg);
+ border-radius: 10px;
+ min-height: 0;
+ height: 100%;
+}
+
+.quotes-input-row {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 12px;
+}
+
+.quotes-input {
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-size: 13px;
+ color: var(--text-primary);
+ background: var(--bg-surface);
+}
+
+.quotes-input:disabled {
+ opacity: 0.75;
+}
+
+.quotes-preview-payload {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.quotes-preview-payload pre {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.quotes-preview-canvas {
+ min-height: 500px;
+ height: 100%;
+ border-radius: 10px;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+ overflow: hidden;
+}
+
+.quotes-pdf-frame {
+ width: 100%;
+ height: 100%;
+ border: none;
+ display: block;
+ background: var(--bg-surface);
+}
+
+.quotes-pdf-placeholder {
+ overflow: auto;
+}
+
+.quotes-pdf-page {
+ width: min(100%, 760px);
+ aspect-ratio: 8.5 / 11;
+ min-height: 760px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ background: var(--bg-surface);
+ box-shadow: var(--header-shadow);
+ padding: 24px;
+ color: var(--text-secondary);
+}
+
+.quotes-pdf-page h4 {
+ margin: 0 0 8px;
+ font-size: 16px;
+ color: var(--text-primary);
+}
+
+.quotes-pdf-page p {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+/* ── Logs view ── */
+.quotes-logs-view {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 16px;
+ gap: 12px;
+}
+
+.quotes-logs-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.quotes-logs-refresh {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.quotes-logs-refresh:hover {
+ background: var(--nav-hover-bg);
+ color: var(--text-primary);
+}
+
+.quotes-logs-refresh:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.quotes-logs-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.quotes-logs-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.quotes-log-group {
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.quotes-log-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--nav-hover-bg);
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.quotes-log-filename {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.quotes-log-count {
+ font-size: 11px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ margin-left: 8px;
+}
+
+.quotes-log-entries {
+ display: flex;
+ flex-direction: column;
+}
+
+.quotes-log-entry {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ font-size: 12px;
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+}
+
+.quotes-log-entry:last-child {
+ border-bottom: none;
+}
+
+.quotes-log-entry:hover {
+ background: var(--nav-hover-bg);
+}
+
+.quotes-log-action {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: capitalize;
+ width: 80px;
+ flex-shrink: 0;
+}
+
+.quotes-log-action.download {
+ color: var(--accent-primary, #3b82f6);
+}
+
+.quotes-log-action.print {
+ color: var(--warning, #e6a817);
+}
+
+.quotes-log-user {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-primary);
+}
+
+.quotes-log-date {
+ flex-shrink: 0;
+ font-size: 11px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
/* ── Empty state ── */
.tab-empty {
display: flex;
@@ -2041,6 +3327,26 @@
grid-template-columns: 1fr;
}
+ .quotes-fields-grid,
+ .quotes-flags-grid,
+ .quotes-preview-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .quotes-preview-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .quotes-preview-sidebar {
+ max-height: none;
+ overflow: visible;
+ padding-right: 0;
+ }
+
+ .quotes-preview-canvas {
+ height: 480px;
+ }
+
.forecasts-summary {
grid-template-columns: 1fr;
}
diff --git a/src/styles/sales/sales.css b/src/styles/sales/sales.css
index 19abb29..f69fc9e 100644
--- a/src/styles/sales/sales.css
+++ b/src/styles/sales/sales.css
@@ -323,7 +323,7 @@
.col-stage,
.col-status,
-.col-priority {
+.col-rating {
min-width: 120px;
}
@@ -368,9 +368,33 @@
letter-spacing: 0.03em;
}
+/* ── FutureLead: muted purple — not yet in pipeline ── */
+.sales-status-badge.status-future {
+ background: rgba(139, 92, 246, 0.12);
+ color: #7c3aed;
+}
+
+/* ── New: teal — freshly entered ── */
+.sales-status-badge.status-new {
+ background: rgba(20, 184, 166, 0.12);
+ color: #0d9488;
+}
+
+/* ── Internal Review: amber — needs internal action ── */
+.sales-status-badge.status-review {
+ background: rgba(245, 158, 11, 0.12);
+ color: #d97706;
+}
+
+/* ── Active: green — actively being worked ── */
+.sales-status-badge.status-active {
+ background: rgba(34, 197, 94, 0.12);
+ color: #16a34a;
+}
+
.sales-status-badge.status-open {
- background: var(--status-active-bg, #dcfce7);
- color: var(--status-active-color, #16a34a);
+ background: rgba(34, 197, 94, 0.12);
+ color: #16a34a;
}
.sales-status-badge.status-won {
@@ -424,12 +448,46 @@
opacity: 1;
}
-.sales-priority {
- font-size: 12px;
- font-weight: 600;
+/* ── Rating badge (heat dots) ── */
+.sales-rating-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+}
+
+.sales-rating-badge.heat-hot {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+.sales-rating-badge.heat-warm {
+ background: rgba(245, 158, 11, 0.14);
+ color: #d97706;
+}
+
+.sales-rating-badge.heat-cold {
+ background: rgba(56, 189, 248, 0.12);
+ color: #0ea5e9;
+}
+
+.sales-rating-badge.heat-neutral {
+ background: var(--nav-hover-bg);
color: var(--text-secondary);
}
+.sales-heat-dots {
+ display: inline-flex;
+ gap: 2px;
+ align-items: center;
+}
+
.sales-footer {
display: flex;
align-items: center;