feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"axios": "^1.13.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"socket.io-client": "^4.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -44,6 +45,11 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
],
|
||||
"packages": {
|
||||
"@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=="],
|
||||
|
||||
"@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.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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
"productName": "electron-svelte",
|
||||
"description": "Electron Svelte",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"main": ".vite/build/main.js",
|
||||
"author": {
|
||||
@@ -60,6 +60,7 @@
|
||||
"axios": "^1.13.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -358,6 +358,7 @@ describe("optima api modules", () => {
|
||||
await sales.fetchOne("token", "opp-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", {
|
||||
params: {},
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
});
|
||||
@@ -475,7 +476,7 @@ describe("optima api modules", () => {
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
`/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" } },
|
||||
);
|
||||
});
|
||||
|
||||
// ── 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,13 @@ export interface SalesOpportunity {
|
||||
name: string;
|
||||
description?: 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;
|
||||
status?: { id?: number; name?: string } | null;
|
||||
priority?: { id?: number; name?: string } | null;
|
||||
@@ -76,6 +82,7 @@ export interface SalesOpportunity {
|
||||
dateBecameLead?: string | null;
|
||||
closedDate?: string | null;
|
||||
closedFlag?: boolean;
|
||||
probability?: { id?: number; percent?: number } | null;
|
||||
closedBy?:
|
||||
| string
|
||||
| { id?: number | string; identifier?: string; name?: string }
|
||||
@@ -187,6 +194,99 @@ export interface CancelOpportunityProductBody {
|
||||
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 = {
|
||||
async fetchMany(
|
||||
accessToken: string,
|
||||
@@ -220,7 +320,7 @@ export const sales = {
|
||||
async fetchOne(
|
||||
accessToken: string,
|
||||
identifier: string,
|
||||
include?: ("notes" | "contacts" | "products")[],
|
||||
include?: ("notes" | "contacts" | "products" | "quotes")[],
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (include && include.length > 0) {
|
||||
@@ -462,4 +562,136 @@ export const sales = {
|
||||
);
|
||||
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;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 @@
|
||||
<th class="col-company">Company</th>
|
||||
<th class="col-stage">Stage</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-close">Expected Close</th>
|
||||
<th class="col-updated">Updated</th>
|
||||
@@ -441,10 +486,24 @@
|
||||
{statusLabel(opp)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-priority">
|
||||
<span class="sales-priority">
|
||||
{priorityLabel(opp)}
|
||||
<td class="col-rating">
|
||||
{#if opp.rating?.name}
|
||||
<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 class="col-owner">{ownerLabel(opp)}</td>
|
||||
<td class="col-close">
|
||||
|
||||
@@ -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 @@
|
||||
<th class="col-company">Company</th>
|
||||
<th class="col-stage">Stage</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-close">Expected Close</th>
|
||||
<th class="col-updated">Updated</th>
|
||||
@@ -441,10 +486,24 @@
|
||||
{statusLabel(opp)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-priority">
|
||||
<span class="sales-priority">
|
||||
{priorityLabel(opp)}
|
||||
<td class="col-rating">
|
||||
{#if opp.rating?.name}
|
||||
<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 class="col-owner">{ownerLabel(opp)}</td>
|
||||
<td class="col-close">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
<!-- Mobile vertical nav menu -->
|
||||
{#if isMobile && mobileActiveTab === null}
|
||||
<div class="mobile-nav-menu">
|
||||
{#each tabs as tab}
|
||||
{#each visibleTabs as tab}
|
||||
<button
|
||||
class="mobile-nav-item"
|
||||
on:click={() => selectMobileTab(tab)}
|
||||
@@ -183,6 +191,22 @@
|
||||
d="M16 3.13a4 4 0 010 7.75"
|
||||
/>
|
||||
</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}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -206,6 +230,9 @@
|
||||
{#if tab === "Contacts" && contacts.length > 0}
|
||||
<span class="mobile-nav-badge">{contacts.length}</span>
|
||||
{/if}
|
||||
{#if tab === "Quotes" && quotes.length > 0}
|
||||
<span class="mobile-nav-badge">{quotes.length}</span>
|
||||
{/if}
|
||||
<svg
|
||||
class="mobile-nav-chevron"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -252,7 +279,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="tab-bar" role="tablist">
|
||||
{#each tabs as tab}
|
||||
{#each visibleTabs as tab}
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={activeTab === tab}
|
||||
@@ -270,6 +297,9 @@
|
||||
{#if tab === "Contacts" && contacts.length > 0}
|
||||
<span class="tab-count-badge">{contacts.length}</span>
|
||||
{/if}
|
||||
{#if tab === "Quotes" && quotes.length > 0}
|
||||
<span class="tab-count-badge">{quotes.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -293,6 +323,13 @@
|
||||
on:sequenceSaved={handleSequenceSaved}
|
||||
on:productsChanged={handleProductsChanged}
|
||||
/>
|
||||
{:else if activeTab === "Quotes"}
|
||||
<QuotesTab
|
||||
accessToken={data.accessToken}
|
||||
opportunityId={data.opportunityId}
|
||||
initialQuotes={quotes}
|
||||
{permissions}
|
||||
/>
|
||||
{:else if activeTab === "Notes"}
|
||||
<NotesTab
|
||||
{notes}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -76,16 +148,98 @@
|
||||
<span class="opp-number">#{opportunity.cwOpportunityId}</span>
|
||||
{/if}
|
||||
{#if opportunity.status}
|
||||
<span class="opp-status-badge {statusColorClass(opportunity)}">
|
||||
{opportunity.closedFlag ? "Closed" : opportunity.status.name}
|
||||
<span
|
||||
class="opp-status-badge {statusColorClass(opportunity)}"
|
||||
class:status-equiv={isEquivalencyStatus(opportunity)}
|
||||
data-tooltip={isEquivalencyStatus(opportunity)
|
||||
? `Original: ${originalStatusName(opportunity)}`
|
||||
: undefined}
|
||||
>
|
||||
{statusLabel(opportunity)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if opportunity.type?.name}
|
||||
<span class="opp-type-badge">{opportunity.type.name}</span>
|
||||
{/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>
|
||||
|
||||
<!-- ── 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) ── -->
|
||||
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
|
||||
<div class="opp-byline">
|
||||
|
||||
@@ -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 @@
|
||||
<div class="overview-tab">
|
||||
<!-- ═══ Pipeline Banner ═══ -->
|
||||
<div class="ov-pipeline-banner">
|
||||
<div class="ov-pipeline-stages">
|
||||
{#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 class="ov-pipeline-stages"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Financial KPI Strip ═══ -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<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 {
|
||||
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";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user