feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements

This commit is contained in:
2026-03-06 23:49:27 -06:00
parent 762edd8eb7
commit b735981b6b
17 changed files with 4222 additions and 129 deletions
+34
View File
@@ -8,6 +8,7 @@
"axios": "^1.13.3", "axios": "^1.13.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"pdfjs-dist": "^5.5.207",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
}, },
"devDependencies": { "devDependencies": {
@@ -44,6 +45,11 @@
}, },
}, },
}, },
"trustedDependencies": [
"electron",
"electron-winstaller",
"esbuild",
],
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.3", "", {}, "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA=="], "@adobe/css-tools": ["@adobe/css-tools@4.4.3", "", {}, "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA=="],
@@ -225,6 +231,30 @@
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.96", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.96", "@napi-rs/canvas-darwin-arm64": "0.1.96", "@napi-rs/canvas-darwin-x64": "0.1.96", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", "@napi-rs/canvas-linux-arm64-musl": "0.1.96", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-musl": "0.1.96", "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", "@napi-rs/canvas-win32-x64-msvc": "0.1.96" } }, "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.96", "", { "os": "android", "cpu": "arm64" }, "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.96", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.96", "", { "os": "darwin", "cpu": "x64" }, "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.96", "", { "os": "linux", "cpu": "arm" }, "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.96", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.96", "", { "os": "linux", "cpu": "arm64" }, "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.96", "", { "os": "linux", "cpu": "none" }, "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.96", "", { "os": "linux", "cpu": "x64" }, "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.96", "", { "os": "linux", "cpu": "x64" }, "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.96", "", { "os": "win32", "cpu": "arm64" }, "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.96", "", { "os": "win32", "cpu": "x64" }, "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -1031,6 +1061,8 @@
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" }, "optionalDependencies": { "encoding": "0.1.13" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" }, "optionalDependencies": { "encoding": "0.1.13" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-readable-to-web-readable-stream": ["node-readable-to-web-readable-stream@0.4.2", "", {}, "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "1.1.1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "1.1.1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="],
@@ -1091,6 +1123,8 @@
"pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="], "pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="],
"pdfjs-dist": ["pdfjs-dist@5.5.207", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw=="],
"pe-library": ["pe-library@1.0.1", "", {}, "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg=="], "pe-library": ["pe-library@1.0.1", "", {}, "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
+2 -1
View File
@@ -3,7 +3,7 @@
"productName": "electron-svelte", "productName": "electron-svelte",
"description": "Electron Svelte", "description": "Electron Svelte",
"private": true, "private": true,
"version": "0.0.1", "version": "0.1.7",
"type": "module", "type": "module",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"author": { "author": {
@@ -60,6 +60,7 @@
"axios": "^1.13.3", "axios": "^1.13.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"pdfjs-dist": "^5.5.207",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3"
}, },
"trustedDependencies": [ "trustedDependencies": [
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { browser } from "$app/environment";
export let src: string | null = null;
let container: HTMLDivElement;
let pageCount = 0;
let loading = true;
let error = "";
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
let currentPdf: any = null;
let currentSrc: string | null = null;
let renderGeneration = 0;
// Max canvas dimension to prevent browser OOM
const MAX_CANVAS_DIM = 2048;
async function initPdfJs() {
if (!browser || pdfjsLib) return;
const lib = await import("pdfjs-dist");
// Use fake worker to avoid worker-loading issues with Vite/SvelteKit
lib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
pdfjsLib = lib;
}
async function renderPdf(pdfSrc: string) {
if (!pdfSrc || !container || !browser) return;
// Set currentSrc immediately to prevent re-entry
currentSrc = pdfSrc;
const generation = ++renderGeneration;
loading = true;
error = "";
// Clean up previous PDF
if (currentPdf) {
currentPdf.destroy();
currentPdf = null;
}
container.innerHTML = "";
try {
await initPdfJs();
if (!pdfjsLib || generation !== renderGeneration) return;
const pdf = await pdfjsLib.getDocument({ url: pdfSrc, isEvalSupported: false }).promise;
if (generation !== renderGeneration) {
pdf.destroy();
return;
}
currentPdf = pdf;
pageCount = pdf.numPages;
for (let i = 1; i <= pageCount; i++) {
if (generation !== renderGeneration) return;
const page = await pdf.getPage(i);
// Calculate a safe scale that won't exceed MAX_CANVAS_DIM
const baseViewport = page.getViewport({ scale: 1.0 });
const maxDim = Math.max(baseViewport.width, baseViewport.height);
const safeScale = maxDim > MAX_CANVAS_DIM ? MAX_CANVAS_DIM / maxDim : 1.0;
const viewport = page.getViewport({ scale: safeScale });
const canvas = document.createElement("canvas");
canvas.className = "pdf-viewer-page";
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
container.appendChild(canvas);
await page.render({ canvas, viewport } as any).promise;
// Release page resources after rendering
page.cleanup();
}
} catch (err: unknown) {
if (generation === renderGeneration) {
error = err instanceof Error ? err.message : "Failed to render PDF";
console.error("PDF render error:", err);
}
} finally {
if (generation === renderGeneration) {
loading = false;
}
}
}
function cleanup() {
renderGeneration++;
if (currentPdf) {
currentPdf.destroy();
currentPdf = null;
}
currentSrc = null;
}
// React to src changes (also handles initial render once container is bound)
$: if (browser && src && src !== currentSrc && container) {
renderPdf(src);
}
// Handle no-src case
$: if (browser && !src) {
loading = false;
}
onDestroy(() => {
cleanup();
});
</script>
<div
class="pdf-viewer"
on:contextmenu|preventDefault
role="document"
>
{#if loading}
<div class="pdf-viewer-loading">
<p>Rendering PDF...</p>
</div>
{/if}
{#if error}
<div class="pdf-viewer-error">
<p>{error}</p>
</div>
{/if}
<div class="pdf-viewer-pages" bind:this={container}></div>
</div>
<style>
.pdf-viewer {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: var(--bg-surface-raised, #2a2a2a);
-webkit-user-select: none;
user-select: none;
}
.pdf-viewer-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px;
}
.pdf-viewer-pages :global(.pdf-viewer-page) {
max-width: 100%;
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.pdf-viewer-loading,
.pdf-viewer-error {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-size: 12px;
color: var(--text-muted, #888);
font-style: italic;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockError, mockRedirect } = vi.hoisted(() => ({
mockError: vi.fn(),
mockRedirect: vi.fn(),
}));
vi.mock("@sveltejs/kit", () => ({
error: mockError,
redirect: mockRedirect,
}));
import {
ApiError,
handleApiError,
isInvalidSignatureError,
isNetworkError,
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
} from "./errorHandler";
describe("errorHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
// ── ApiError ──────────────────────────────────────────────────────────
it("ApiError stores statusCode, message, and details", () => {
const err = new ApiError(422, "Validation failed", { field: "name" });
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ApiError");
expect(err.statusCode).toBe(422);
expect(err.message).toBe("Validation failed");
expect(err.details).toEqual({ field: "name" });
});
// ── isInvalidSignatureError ───────────────────────────────────────────
it("detects 'invalid signature' in response data message", () => {
const err = {
response: { data: { message: "Token has invalid signature" } },
};
expect(isInvalidSignatureError(err)).toBe(true);
});
it("detects 'jwt malformed' in response data error field", () => {
const err = {
response: { data: { error: "jwt malformed" } },
};
expect(isInvalidSignatureError(err)).toBe(true);
});
it("detects 'invalid token' in Error message", () => {
const err = new Error("Invalid token received");
expect(isInvalidSignatureError(err)).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isInvalidSignatureError(new Error("network down"))).toBe(false);
expect(isInvalidSignatureError(null)).toBe(false);
expect(isInvalidSignatureError("string error")).toBe(false);
});
// ── handleApiError ────────────────────────────────────────────────────
it("redirects to /logout on invalid signature error", () => {
mockRedirect.mockImplementation(() => {
throw new Error("REDIRECT");
});
const err = {
response: { data: { message: "invalid signature" } },
};
expect(() => handleApiError(err)).toThrow("REDIRECT");
expect(mockRedirect).toHaveBeenCalledWith(303, "/logout");
});
it("throws SvelteKit error for ApiError", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
const err = new ApiError(404, "Not found", "extra");
expect(() => handleApiError(err)).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(404, {
message: "Not found",
details: "extra",
});
});
it("throws 500 error for generic Error", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
const err = new Error("Something broke");
expect(() => handleApiError(err)).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(500, {
message: "Something broke",
details: expect.any(String),
});
});
it("throws 500 error for non-Error values", () => {
mockError.mockImplementation(() => {
throw new Error("HTTP_ERROR");
});
expect(() => handleApiError("string error")).toThrow("HTTP_ERROR");
expect(mockError).toHaveBeenCalledWith(500, {
message: "An unexpected error occurred",
details: "string error",
});
});
// ── isNetworkError ────────────────────────────────────────────────────
it("identifies Network errors", () => {
expect(isNetworkError(new Error("Network request failed"))).toBe(true);
expect(isNetworkError(new Error("fetch failed"))).toBe(true);
expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true);
});
it("returns false for non-network errors", () => {
expect(isNetworkError(new Error("validation error"))).toBe(false);
expect(isNetworkError("not an error")).toBe(false);
});
// ── status code helpers ───────────────────────────────────────────────
it("isUnauthorizedError returns true for 401 ApiError", () => {
expect(isUnauthorizedError(new ApiError(401, "Unauthorized"))).toBe(true);
expect(isUnauthorizedError(new ApiError(403, "Forbidden"))).toBe(false);
expect(isUnauthorizedError(new Error("nope"))).toBe(false);
});
it("isForbiddenError returns true for 403 ApiError", () => {
expect(isForbiddenError(new ApiError(403, "Forbidden"))).toBe(true);
expect(isForbiddenError(new ApiError(401, "Unauthorized"))).toBe(false);
});
it("isNotFoundError returns true for 404 ApiError", () => {
expect(isNotFoundError(new ApiError(404, "Not Found"))).toBe(true);
expect(isNotFoundError(new ApiError(500, "Server Error"))).toBe(false);
});
});
+464 -1
View File
@@ -358,6 +358,7 @@ describe("optima api modules", () => {
await sales.fetchOne("token", "opp-1"); await sales.fetchOne("token", "opp-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", { expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", {
params: {},
headers: { Authorization: "Bearer token" }, headers: { Authorization: "Bearer token" },
}); });
}); });
@@ -475,7 +476,7 @@ describe("optima api modules", () => {
expect(mockApi.get).toHaveBeenCalledWith( expect(mockApi.get).toHaveBeenCalledWith(
`/v1/sales/opportunities/${encodeURIComponent("opp/special#1")}`, `/v1/sales/opportunities/${encodeURIComponent("opp/special#1")}`,
{ headers: { Authorization: "Bearer token" } }, { params: {}, headers: { Authorization: "Bearer token" } },
); );
}); });
@@ -540,4 +541,466 @@ describe("optima api modules", () => {
{ headers: { Authorization: "Bearer token" } }, { headers: { Authorization: "Bearer token" } },
); );
}); });
// ── company: missing methods ──────────────────────────────────────────
it("company.fetch retrieves a single company with options", async () => {
mockApi.get.mockResolvedValueOnce({ data: { id: "c1", name: "Acme" } });
const result = await company.fetch("token", "c1", {
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: true,
});
expect(mockApi.get).toHaveBeenCalledWith("/v1/company/companies/c1", {
params: {
includeAddress: "true",
includePrimaryContact: "true",
includeAllContacts: "true",
},
headers: { Authorization: "Bearer token" },
});
expect(result).toEqual({ id: "c1", name: "Acme" });
});
it("company.fetch works without options", async () => {
mockApi.get.mockResolvedValueOnce({ data: { id: "c1" } });
await company.fetch("token", "c1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/company/companies/c1", {
params: {},
headers: { Authorization: "Bearer token" },
});
});
it("company.fetchConfigurations calls configurations endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await company.fetchConfigurations("token", "c1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/company/companies/c1/configurations",
{ headers: { Authorization: "Bearer token" } },
);
});
// ── credential: missing methods ───────────────────────────────────────
it("credential.fetchByCompany calls company endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await credential.fetchByCompany("token", "comp-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/credential/credentials/company/comp-1",
{ headers: { Authorization: "Bearer token" } },
);
});
it("credential.update patches credential", async () => {
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
await credential.update("token", "cred-1", {
name: "New Name",
notes: "Updated",
});
expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/credential/credentials/cred-1",
{ name: "New Name", notes: "Updated" },
{ headers: { Authorization: "Bearer token" } },
);
});
it("credential.fetchSecureValue calls secure-values endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: "secret" } });
await credential.fetchSecureValue("token", "cred-1", "field-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/credential/credentials/cred-1/secure-values/field-1",
{ headers: { Authorization: "Bearer token" } },
);
});
it("credential.fetchValueTypes calls valuetypes endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: ["text", "password"] } });
await credential.fetchValueTypes("token");
expect(mockApi.get).toHaveBeenCalledWith("/v1/credential/valuetypes", {
headers: { Authorization: "Bearer token" },
});
});
// ── credentialType: missing methods ───────────────────────────────────
it("credentialType.fetchMany lists all types", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await credentialType.fetchMany("token");
expect(mockApi.get).toHaveBeenCalledWith("/v1/credential-type", {
headers: { Authorization: "Bearer token" },
});
});
it("credentialType.fetch retrieves a single type", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "ct-1" } } });
await credentialType.fetch("token", "ct-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/credential-type/ct-1", {
headers: { Authorization: "Bearer token" },
});
});
it("credentialType.update patches a type", async () => {
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
await credentialType.update("token", "ct-1", { name: "Updated" });
expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/credential-type/ct-1",
{ name: "Updated" },
{ headers: { Authorization: "Bearer token" } },
);
});
it("credentialType.fetchCredentials retrieves credentials for a type", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await credentialType.fetchCredentials("token", "ct-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/credential-type/ct-1/credentials",
{ headers: { Authorization: "Bearer token" } },
);
});
// ── procurement: missing methods ──────────────────────────────────────
it("procurement.fetch retrieves a single item", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "item-1" } } });
await procurement.fetch("token", "item-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items/item-1", {
params: {},
headers: { Authorization: "Bearer token" },
});
});
it("procurement.fetch passes includeLinkedItems option", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "item-1" } } });
await procurement.fetch("token", "item-1", { includeLinkedItems: true });
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items/item-1", {
params: { includeLinkedItems: "true" },
headers: { Authorization: "Bearer token" },
});
});
it("procurement.refreshInventory posts to refresh endpoint", async () => {
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
await procurement.refreshInventory("token", "item-1");
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/procurement/items/item-1/refresh-inventory",
{},
{ headers: { Authorization: "Bearer token" } },
);
});
it("procurement.fetchLinkedItems calls linked endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await procurement.fetchLinkedItems("token", "item-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/procurement/items/item-1/linked",
{ headers: { Authorization: "Bearer token" } },
);
});
// ── role: missing methods ─────────────────────────────────────────────
it("role.fetchMany lists all roles", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await role.fetchMany("token");
expect(mockApi.get).toHaveBeenCalledWith("/v1/role", {
headers: { Authorization: "Bearer token" },
});
});
it("role.fetch retrieves a single role", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "r-1" } } });
await role.fetch("token", "r-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/role/r-1", {
headers: { Authorization: "Bearer token" },
});
});
it("role.create posts role data", async () => {
const data = { title: "Admin", moniker: "admin", permissions: ["a"] };
mockApi.post.mockResolvedValueOnce({ data: { data } });
await role.create("token", data as any);
expect(mockApi.post).toHaveBeenCalledWith("/v1/role", data, {
headers: { Authorization: "Bearer token" },
});
});
it("role.update patches a role", async () => {
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
await role.update("token", "r-1", { title: "Super Admin" });
expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/role/r-1",
{ title: "Super Admin" },
{ headers: { Authorization: "Bearer token" } },
);
});
it("role.delete removes a role", async () => {
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
await role.delete("token", "r-1");
expect(mockApi.delete).toHaveBeenCalledWith("/v1/role/r-1", {
headers: { Authorization: "Bearer token" },
});
});
it("role.fetchUsers lists users in a role", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await role.fetchUsers("token", "r-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/role/r-1/users", {
headers: { Authorization: "Bearer token" },
});
});
// ── sales: missing methods ────────────────────────────────────────────
it("sales.fetchOne passes include params when provided", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "opp-1" } } });
await sales.fetchOne("token", "opp-1", ["notes", "contacts", "products"]);
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", {
params: { include: "notes,contacts,products" },
headers: { Authorization: "Bearer token" },
});
});
it("sales.addProduct posts product body", async () => {
const body = { quantity: 3, revenue: 100, cost: 50 };
mockApi.post.mockResolvedValueOnce({ data: { data: body } });
await sales.addProduct("token", "opp-1", body);
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products",
body,
{ headers: { Authorization: "Bearer token" } },
);
});
it("sales.addSpecialOrder posts special order body", async () => {
const body = { desc: "Custom widget", price: 250 };
mockApi.post.mockResolvedValueOnce({ data: { data: body } });
await sales.addSpecialOrder("token", "opp-1", body);
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products/special-order",
body,
{ headers: { Authorization: "Bearer token" } },
);
});
it("sales.fetchLaborOptions calls labor options endpoint", async () => {
mockApi.get.mockResolvedValueOnce({
data: { data: { defaults: {}, options: {} } },
});
await sales.fetchLaborOptions("token", "opp-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products/labor/options",
{ headers: { Authorization: "Bearer token" } },
);
});
it("sales.addLabor posts labor body", async () => {
const body = { laborStyle: "field" as const, hours: 8 };
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
await sales.addLabor("token", "opp-1", body);
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products/labor",
body,
{ headers: { Authorization: "Bearer token" } },
);
});
it("sales.editProduct patches product with ID", async () => {
const body = { productDescription: "Updated", quantity: 5 };
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
await sales.editProduct("token", "opp-1", 10, body);
expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products/10/edit",
body,
{ headers: { Authorization: "Bearer token" } },
);
});
it("sales.cancelProduct patches product cancellation", async () => {
const body = { quantityCancelled: 2, cancellationReason: "Out of stock" };
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
await sales.cancelProduct("token", "opp-1", 10, body);
expect(mockApi.patch).toHaveBeenCalledWith(
"/v1/sales/opportunities/opp-1/products/10/cancel",
body,
{ headers: { Authorization: "Bearer token" } },
);
});
// ── unifi: missing methods ────────────────────────────────────────────
it("unifi.fetchSite retrieves a single site", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "s-1" } } });
await unifi.fetchSite("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchCompanySites retrieves sites for a company", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchCompanySites("token", "comp-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/company/companies/comp-1/unifi/sites",
{ headers: { Authorization: "Bearer token" } },
);
});
it("unifi.fetchSiteOverview calls overview endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: {} } });
await unifi.fetchSiteOverview("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/overview", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchSiteDevices calls devices endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchSiteDevices("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/devices", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchSiteNetworks calls networks endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchSiteNetworks("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/networks", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchWlanGroups calls wlan-groups endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchWlanGroups("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/wlan-groups", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchApGroups calls ap-groups endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchApGroups("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/ap-groups", {
headers: { Authorization: "Bearer token" },
});
});
it("unifi.fetchAccessPoints calls access-points endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchAccessPoints("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/unifi/site/s-1/access-points",
{ headers: { Authorization: "Bearer token" } },
);
});
it("unifi.fetchSpeedProfiles calls speed-profiles endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
await unifi.fetchSpeedProfiles("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/v1/unifi/site/s-1/speed-profiles",
{ headers: { Authorization: "Bearer token" } },
);
});
it("unifi.createSpeedProfile posts speed profile data", async () => {
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
await unifi.createSpeedProfile("token", "s-1", {
name: "Fast",
downloadLimitKbps: 100000,
uploadLimitKbps: 50000,
});
expect(mockApi.post).toHaveBeenCalledWith(
"/v1/unifi/site/s-1/speed-profiles",
{ name: "Fast", downloadLimitKbps: 100000, uploadLimitKbps: 50000 },
{ headers: { Authorization: "Bearer token" } },
);
});
it("unifi.fetchWifiLimits calls wifi-limits endpoint", async () => {
mockApi.get.mockResolvedValueOnce({ data: { data: {} } });
await unifi.fetchWifiLimits("token", "s-1");
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/site/s-1/wifi-limits", {
headers: { Authorization: "Bearer token" },
});
});
}); });
+234 -2
View File
@@ -6,7 +6,13 @@ export interface SalesOpportunity {
name: string; name: string;
description?: string | null; description?: string | null;
notes?: string | null; notes?: string | null;
type?: { id?: number; name?: string } | null; type?: {
id?: number;
name?: string;
wonFlag?: boolean;
lostFlag?: boolean;
closedFlag?: boolean;
} | null;
stage?: { id?: number; name?: string } | null; stage?: { id?: number; name?: string } | null;
status?: { id?: number; name?: string } | null; status?: { id?: number; name?: string } | null;
priority?: { id?: number; name?: string } | null; priority?: { id?: number; name?: string } | null;
@@ -76,6 +82,7 @@ export interface SalesOpportunity {
dateBecameLead?: string | null; dateBecameLead?: string | null;
closedDate?: string | null; closedDate?: string | null;
closedFlag?: boolean; closedFlag?: boolean;
probability?: { id?: number; percent?: number } | null;
closedBy?: closedBy?:
| string | string
| { id?: number | string; identifier?: string; name?: string } | { id?: number | string; identifier?: string; name?: string }
@@ -187,6 +194,99 @@ export interface CancelOpportunityProductBody {
cancellationReason?: string | null; cancellationReason?: string | null;
} }
export interface QuoteRegenProduct {
cwForecastId?: number;
forecastDescription?: string;
productDescription?: string;
customerDescription?: string;
productNarrative?: string;
productClass?: string;
forecastType?: string;
catalogItem?: { id?: number; identifier?: string };
quantity?: number;
effectiveQuantity?: number;
revenue?: number;
cost?: number;
margin?: number;
percentage?: number;
includeFlag?: boolean;
taxableFlag?: boolean;
recurringFlag?: boolean;
recurringRevenue?: number;
recurringCost?: number;
sequenceNumber?: number;
cancelledFlag?: boolean;
cancellationType?: string | null;
quantityCancelled?: number;
cancelledReason?: string | null;
cancelledDate?: string | null;
}
export interface QuoteDownloadRecord {
downloadedAt: string;
fetchAction: "download" | "print";
userId: string;
userName: string;
userEmail: string;
}
export interface QuoteDownloadLog {
quoteId: string;
quoteFileName: string;
createdById: string;
createdAt: string;
downloads: QuoteDownloadRecord[];
}
export interface CommittedQuote {
id: string;
quoteFileName: string;
quoteRegenHash?: string;
opportunityId: string;
createdById: string;
quoteRegenData?: {
options?: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
showPreview?: boolean;
};
opportunity?: {
id?: string;
cwOpportunityId?: number;
name?: string;
totalSalesTax?: number;
contactName?: string;
companyName?: string;
};
customer?: {
preparedFor?: string;
companyName?: string;
primaryContact?: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
siteAddress?: string[];
companyAddress?: string[];
};
salesRep?: {
name?: string;
email?: string;
};
quoteNarrative?: string;
products?: QuoteRegenProduct[];
snapshotTimestamp?: string;
} | null;
quoteRegenParams?: {
opportunityId?: string;
cwOpportunityId?: number;
} | null;
createdAt: string;
updatedAt: string;
}
export const sales = { export const sales = {
async fetchMany( async fetchMany(
accessToken: string, accessToken: string,
@@ -220,7 +320,7 @@ export const sales = {
async fetchOne( async fetchOne(
accessToken: string, accessToken: string,
identifier: string, identifier: string,
include?: ("notes" | "contacts" | "products")[], include?: ("notes" | "contacts" | "products" | "quotes")[],
) { ) {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (include && include.length > 0) { if (include && include.length > 0) {
@@ -462,4 +562,136 @@ export const sales = {
); );
return response.data; return response.data;
}, },
async fetchQuotes(
accessToken: string,
identifier: string,
options?: {
includeRegenData?: boolean;
includeRegenParams?: boolean;
},
) {
const params: Record<string, string> = {};
if (options?.includeRegenData) params.includeRegenData = "true";
if (options?.includeRegenParams) params.includeRegenParams = "true";
const response = await api.get(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/quotes`,
{
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data as {
status?: number;
message?: string;
data: CommittedQuote[];
successful?: boolean;
};
},
async commitQuote(
accessToken: string,
identifier: string,
body?: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
},
) {
const response = await api.post(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/quote/commit`,
body ?? {},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data as {
status?: number;
message?: string;
data: {
id: string;
quoteFileName: string;
opportunityId: string;
createdById: string;
quoteRegenData: {
lineItemPricing: boolean;
includeQuoteNarrative: boolean;
includeItemNarratives: boolean;
showPreview: boolean;
};
createdAt: string;
updatedAt: string;
};
successful?: boolean;
};
},
async previewQuote(accessToken: string, identifier: string, quoteId: string) {
const response = await api.get(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/quote/${encodeURIComponent(quoteId)}/preview`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data as {
status?: number;
message?: string;
data: {
mimeType: string;
contentBase64: string;
};
successful?: boolean;
};
},
async downloadQuote(
accessToken: string,
identifier: string,
quoteId: string,
fetchAction: "download" | "print",
) {
const response = await api.get(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/quote/${encodeURIComponent(quoteId)}/download`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
params: { fetchAction },
},
);
return response.data as {
status?: number;
message?: string;
data: {
id: string;
quoteFileName: string;
mimeType: string;
contentBase64: string;
};
successful?: boolean;
};
},
async fetchQuoteDownloads(accessToken: string, identifier: string) {
const response = await api.get(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/quotes/downloads`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data as {
status?: number;
message?: string;
data: QuoteDownloadLog[];
successful?: boolean;
};
},
}; };
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockBrowser } = vi.hoisted(() => ({
mockBrowser: { value: true },
}));
vi.mock("$app/environment", () => ({
get browser() {
return mockBrowser.value;
},
}));
// Mock localStorage and document before importing theme
const mockLocalStorage: Record<string, string> = {};
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();
});
});
+68 -9
View File
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null; 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 { 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); const t = resolvedType(op);
if (!t) return "status-open"; if (!t) return "status-open";
if (t.wonFlag) return "status-won"; if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost"; if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed"; if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive"; 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"; return "status-open";
} }
@@ -253,8 +263,43 @@
return op.company?.name || "—"; return op.company?.name || "—";
} }
function priorityLabel(op: SalesOpportunity): string { function ratingHeatClass(name: string | undefined): string {
return op.priority?.name || "—"; 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 | "...")[] { function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
<th class="col-company">Company</th> <th class="col-company">Company</th>
<th class="col-stage">Stage</th> <th class="col-stage">Stage</th>
<th class="col-status">Status</th> <th class="col-status">Status</th>
<th class="col-priority">Priority</th> <th class="col-rating">Rating</th>
<th class="col-owner">Owner</th> <th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th> <th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th> <th class="col-updated">Updated</th>
@@ -441,10 +486,24 @@
{statusLabel(opp)} {statusLabel(opp)}
</span> </span>
</td> </td>
<td class="col-priority"> <td class="col-rating">
<span class="sales-priority"> {#if opp.rating?.name}
{priorityLabel(opp)} <span
</span> class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
></span>
{/each}
</span>
{opp.rating.name}
</span>
{:else}
{/if}
</td> </td>
<td class="col-owner">{ownerLabel(opp)}</td> <td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close"> <td class="col-close">
+68 -9
View File
@@ -233,15 +233,25 @@
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null; 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 { 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); const t = resolvedType(op);
if (!t) return "status-open"; if (!t) return "status-open";
if (t.wonFlag) return "status-won"; if (t.wonFlag) return "status-won";
if (t.lostFlag) return "status-lost"; if (t.lostFlag) return "status-lost";
if (t.closedFlag) return "status-closed"; if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive"; 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"; return "status-open";
} }
@@ -253,8 +263,43 @@
return op.company?.name || "—"; return op.company?.name || "—";
} }
function priorityLabel(op: SalesOpportunity): string { function ratingHeatClass(name: string | undefined): string {
return op.priority?.name || "—"; 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 | "...")[] { function getPageNumbers(current: number, total: number): (number | "...")[] {
@@ -404,7 +449,7 @@
<th class="col-company">Company</th> <th class="col-company">Company</th>
<th class="col-stage">Stage</th> <th class="col-stage">Stage</th>
<th class="col-status">Status</th> <th class="col-status">Status</th>
<th class="col-priority">Priority</th> <th class="col-rating">Rating</th>
<th class="col-owner">Owner</th> <th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th> <th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th> <th class="col-updated">Updated</th>
@@ -441,10 +486,24 @@
{statusLabel(opp)} {statusLabel(opp)}
</span> </span>
</td> </td>
<td class="col-priority"> <td class="col-rating">
<span class="sales-priority"> {#if opp.rating?.name}
{priorityLabel(opp)} <span
</span> class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
></span>
{/each}
</span>
{opp.rating.name}
</span>
{:else}
{/if}
</td> </td>
<td class="col-owner">{ownerLabel(opp)}</td> <td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close"> <td class="col-close">
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes: [], notes: [],
contacts: [], contacts: [],
products: [], products: [],
quotes: [],
accessToken: null, accessToken: null,
permissions: {} as PermissionMap, permissions: {} as PermissionMap,
}; };
@@ -22,6 +23,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"notes", "notes",
"contacts", "contacts",
"products", "products",
"quotes",
]), ]),
checkPermissions(accessToken, [ checkPermissions(accessToken, [
"sales.opportunity.fetch", "sales.opportunity.fetch",
@@ -29,6 +31,11 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.note.create", "sales.opportunity.note.create",
"sales.opportunity.note.update", "sales.opportunity.note.update",
"sales.opportunity.note.delete", "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 notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? []; const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? []; const products = result?.data?.products ?? [];
const quotes = result?.data?.quotes ?? [];
return { return {
opportunity, opportunity,
@@ -50,6 +58,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
notes, notes,
contacts, contacts,
products, products,
quotes,
accessToken, accessToken,
permissions, permissions,
}; };
+39 -2
View File
@@ -11,6 +11,7 @@
import ContactsTab from "./components/ContactsTab.svelte"; import ContactsTab from "./components/ContactsTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte"; import ActivityTab from "./components/ActivityTab.svelte";
import ProductsTab from "./components/ProductsTab.svelte"; import ProductsTab from "./components/ProductsTab.svelte";
import QuotesTab from "./components/QuotesTab.svelte";
export let data: PageData; export let data: PageData;
@@ -19,6 +20,7 @@
$: notes = data.notes; $: notes = data.notes;
$: contacts = data.contacts; $: contacts = data.contacts;
$: products = data.products; $: products = data.products;
$: quotes = data.quotes ?? [];
$: permissions = data.permissions; $: permissions = data.permissions;
let localProductSequence: number[] | null = let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null; data.opportunity?.productSequence ?? null;
@@ -48,6 +50,7 @@
const tabs = [ const tabs = [
"Overview", "Overview",
"Products", "Products",
"Quotes",
"Notes", "Notes",
"Contacts", "Contacts",
"Activity", "Activity",
@@ -55,6 +58,11 @@
type Tab = (typeof tabs)[number]; type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview"; 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 // Track whether ProductsTab is in edit mode
let productsEditing = false; let productsEditing = false;
@@ -126,7 +134,7 @@
<!-- Mobile vertical nav menu --> <!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null} {#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu"> <div class="mobile-nav-menu">
{#each tabs as tab} {#each visibleTabs as tab}
<button <button
class="mobile-nav-item" class="mobile-nav-item"
on:click={() => selectMobileTab(tab)} on:click={() => selectMobileTab(tab)}
@@ -183,6 +191,22 @@
d="M16 3.13a4 4 0 010 7.75" d="M16 3.13a4 4 0 010 7.75"
/> />
</svg> </svg>
{:else if tab === "Quotes"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<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="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
{:else} {:else}
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -206,6 +230,9 @@
{#if tab === "Contacts" && contacts.length > 0} {#if tab === "Contacts" && contacts.length > 0}
<span class="mobile-nav-badge">{contacts.length}</span> <span class="mobile-nav-badge">{contacts.length}</span>
{/if} {/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="mobile-nav-badge">{quotes.length}</span>
{/if}
<svg <svg
class="mobile-nav-chevron" class="mobile-nav-chevron"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -252,7 +279,7 @@
{/if} {/if}
<div class="tab-bar" role="tablist"> <div class="tab-bar" role="tablist">
{#each tabs as tab} {#each visibleTabs as tab}
<button <button
class="tab-btn" class="tab-btn"
class:active={activeTab === tab} class:active={activeTab === tab}
@@ -270,6 +297,9 @@
{#if tab === "Contacts" && contacts.length > 0} {#if tab === "Contacts" && contacts.length > 0}
<span class="tab-count-badge">{contacts.length}</span> <span class="tab-count-badge">{contacts.length}</span>
{/if} {/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="tab-count-badge">{quotes.length}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@@ -293,6 +323,13 @@
on:sequenceSaved={handleSequenceSaved} on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged} on:productsChanged={handleProductsChanged}
/> />
{:else if activeTab === "Quotes"}
<QuotesTab
accessToken={data.accessToken}
opportunityId={data.opportunityId}
initialQuotes={quotes}
{permissions}
/>
{:else if activeTab === "Notes"} {:else if activeTab === "Notes"}
<NotesTab <NotesTab
{notes} {notes}
@@ -1,9 +1,34 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; 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 opportunity: SalesOpportunity | null;
export let isMobile: boolean; export let isMobile: boolean;
@@ -44,6 +69,53 @@
if (typeof closedBy === "string") return closedBy; if (typeof closedBy === "string") return closedBy;
return closedBy.name ?? closedBy.identifier ?? String(closedBy.id ?? ""); 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";
}
</script> </script>
<div <div
@@ -76,16 +148,98 @@
<span class="opp-number">#{opportunity.cwOpportunityId}</span> <span class="opp-number">#{opportunity.cwOpportunityId}</span>
{/if} {/if}
{#if opportunity.status} {#if opportunity.status}
<span class="opp-status-badge {statusColorClass(opportunity)}"> <span
{opportunity.closedFlag ? "Closed" : opportunity.status.name} class="opp-status-badge {statusColorClass(opportunity)}"
class:status-equiv={isEquivalencyStatus(opportunity)}
data-tooltip={isEquivalencyStatus(opportunity)
? `Original: ${originalStatusName(opportunity)}`
: undefined}
>
{statusLabel(opportunity)}
</span> </span>
{/if} {/if}
{#if opportunity.type?.name} {#if opportunity.type?.name}
<span class="opp-type-badge">{opportunity.type.name}</span> <span class="opp-type-badge">{opportunity.type.name}</span>
{/if} {/if}
{#if opportunity.type?.wonFlag || opportunity.type?.lostFlag}
<span
class="opp-outcome-badge {opportunity.type.wonFlag
? 'outcome-won'
: 'outcome-lost'}"
>
{opportunity.type.wonFlag ? "Won" : "Lost"}
</span>
{/if}
{#if opportunity.rating?.name}
<span
class="opp-rating-badge {ratingHeatClass(
opportunity.rating.name,
)}"
>
<span class="opp-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opportunity.rating.name)}
></span>
{/each}
</span>
{opportunity.rating.name}
</span>
{/if}
{#if opportunity.probability?.percent != null}
<span
class="opp-probability-badge {probabilityTier(
opportunity.probability.percent,
)}"
>
<svg
viewBox="0 0 20 20"
width="10"
height="10"
class="opp-prob-ring"
>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2.5"
opacity="0.15"
/>
<circle
cx="10"
cy="10"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-dasharray={`${opportunity.probability.percent * 0.5027} 50.27`}
stroke-linecap="round"
transform="rotate(-90 10 10)"
/>
</svg>
{opportunity.probability.percent}%
</span>
{/if}
</div> </div>
</div> </div>
<!-- ── Days to Close ── -->
{#if daysUntilClose !== null}
<div
class="opp-close-countdown"
class:overdue={daysUntilClose < 0}
class:soon={daysUntilClose >= 0 && daysUntilClose <= 14}
>
<span class="opp-close-number">{Math.abs(daysUntilClose)}</span>
<span class="opp-close-unit">
{daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? 's' : ''} overdue`
: `day${daysUntilClose !== 1 ? 's' : ''} to close`}
</span>
</div>
{/if}
<!-- ── Byline (Sales Rep) ── --> <!-- ── Byline (Sales Rep) ── -->
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name} {#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
<div class="opp-byline"> <div class="opp-byline">
@@ -110,7 +110,8 @@
$: isClosedOpportunity = (() => { $: isClosedOpportunity = (() => {
if (!opportunity) return false; if (!opportunity) return false;
const statusText = `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase(); const statusText =
`${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
return ( return (
!!opportunity.closedFlag || !!opportunity.closedFlag ||
!!opportunity.closedDate || !!opportunity.closedDate ||
@@ -129,16 +130,6 @@
return diff; 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 // Age in days
$: ageDays = (() => { $: ageDays = (() => {
if (!opportunity?.createdAt) return null; if (!opportunity?.createdAt) return null;
@@ -202,81 +193,7 @@
<div class="overview-tab"> <div class="overview-tab">
<!-- ═══ Pipeline Banner ═══ --> <!-- ═══ Pipeline Banner ═══ -->
<div class="ov-pipeline-banner"> <div class="ov-pipeline-banner">
<div class="ov-pipeline-stages"> <div class="ov-pipeline-stages"></div>
{#if opportunity?.stage?.name}
<div class="ov-pipeline-chip stage">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{opportunity.stage.name}
</div>
{/if}
{#if opportunity?.priority?.name}
<div class="ov-pipeline-chip priority">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
{opportunity.priority.name}
</div>
{/if}
{#if opportunity?.rating?.name}
<div class="ov-pipeline-chip rating">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{opportunity.rating.name}
</div>
{/if}
</div>
{#if closeOutcomeLabel || daysUntilClose !== null}
<div
class="ov-close-countdown"
class:overdue={!closeOutcomeLabel &&
daysUntilClose !== null &&
daysUntilClose < 0}
class:soon={!closeOutcomeLabel &&
daysUntilClose !== null &&
daysUntilClose >= 0 &&
daysUntilClose <= 14}
>
{#if closeOutcomeLabel}
<span class="ov-close-number">{closeOutcomeLabel}</span>
{#if closeOutcomeLabel !== "WON" && closeOutcomeLabel !== "LOST"}
<span class="ov-close-unit">Closed opportunity</span>
{/if}
{:else if daysUntilClose !== null}
<span class="ov-close-number">{Math.abs(daysUntilClose)}</span>
<span class="ov-close-unit">
{daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
: `day${daysUntilClose !== 1 ? "s" : ""} to close`}
</span>
{/if}
</div>
{/if}
</div> </div>
<!-- ═══ Financial KPI Strip ═══ --> <!-- ═══ Financial KPI Strip ═══ -->
File diff suppressed because it is too large Load Diff
+87 -6
View File
@@ -148,12 +148,93 @@ export function formatCurrency(amount?: number | null): string {
}).format(amount); }).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<number, string> = (() => {
const map: Record<number, string> = {};
// 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<number, string> = {
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 { export function statusColorClass(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) return "status-closed"; if (opportunity.closedFlag) {
const name = opportunity.status?.name?.toLowerCase(); const sid = opportunity.status?.id;
if (!name) return "status-open"; if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
if (name === "won") return "status-won"; return "status-closed";
if (name === "lost") return "status-lost"; }
if (name === "inactive") return "status-inactive"; const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-open"; 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";
}
File diff suppressed because it is too large Load Diff
+64 -6
View File
@@ -323,7 +323,7 @@
.col-stage, .col-stage,
.col-status, .col-status,
.col-priority { .col-rating {
min-width: 120px; min-width: 120px;
} }
@@ -368,9 +368,33 @@
letter-spacing: 0.03em; 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 { .sales-status-badge.status-open {
background: var(--status-active-bg, #dcfce7); background: rgba(34, 197, 94, 0.12);
color: var(--status-active-color, #16a34a); color: #16a34a;
} }
.sales-status-badge.status-won { .sales-status-badge.status-won {
@@ -424,12 +448,46 @@
opacity: 1; opacity: 1;
} }
.sales-priority { /* ── Rating badge (heat dots) ── */
font-size: 12px; .sales-rating-badge {
font-weight: 600; 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); color: var(--text-secondary);
} }
.sales-heat-dots {
display: inline-flex;
gap: 2px;
align-items: center;
}
.sales-footer { .sales-footer {
display: flex; display: flex;
align-items: center; align-items: center;