feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements
This commit is contained in:
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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");
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
|
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>
|
</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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
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>
|
</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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user