diff --git a/bun.lock b/bun.lock index 042211b..de9541e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index f01d245..5538b3e 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/components/PdfViewer.svelte b/src/components/PdfViewer.svelte new file mode 100644 index 0000000..5ae71bd --- /dev/null +++ b/src/components/PdfViewer.svelte @@ -0,0 +1,173 @@ + + +
+ {#if loading} +
+

Rendering PDF...

+
+ {/if} + + {#if error} +
+

{error}

+
+ {/if} + +
+
+ + diff --git a/src/lib/optima-api/errorHandler.spec.ts b/src/lib/optima-api/errorHandler.spec.ts new file mode 100644 index 0000000..9d4bd9a --- /dev/null +++ b/src/lib/optima-api/errorHandler.spec.ts @@ -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); + }); +}); diff --git a/src/lib/optima-api/modules/api-modules.spec.ts b/src/lib/optima-api/modules/api-modules.spec.ts index 4154f4b..a2fc4a2 100644 --- a/src/lib/optima-api/modules/api-modules.spec.ts +++ b/src/lib/optima-api/modules/api-modules.spec.ts @@ -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" }, + }); + }); }); diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index c86ed8f..598bf00 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -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 = {}; 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 = {}; + 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; + }; + }, }; diff --git a/src/lib/theme.spec.ts b/src/lib/theme.spec.ts new file mode 100644 index 0000000..b8cc787 --- /dev/null +++ b/src/lib/theme.spec.ts @@ -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 = {}; +vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => mockLocalStorage[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + mockLocalStorage[key] = value; + }), +}); + +vi.stubGlobal("document", { + documentElement: { + setAttribute: vi.fn(), + }, +}); + +describe("theme store", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.keys(mockLocalStorage).forEach((k) => delete mockLocalStorage[k]); + mockBrowser.value = true; + }); + + it("defaults to dark theme", async () => { + // Re-import to get a fresh store + const { theme } = await import("./theme"); + + let value: string | undefined; + const unsub = theme.subscribe((v) => { + value = v; + }); + + expect(value).toBe("dark"); + unsub(); + }); + + it("toggle switches between dark and light", async () => { + const { theme } = await import("./theme"); + + let value: string | undefined; + const unsub = theme.subscribe((v) => { + value = v; + }); + + theme.toggle(); + expect(value).toBe("light"); + + theme.toggle(); + expect(value).toBe("dark"); + + unsub(); + }); + + it("set updates the theme directly", async () => { + const { theme } = await import("./theme"); + + let value: string | undefined; + const unsub = theme.subscribe((v) => { + value = v; + }); + + theme.set("light"); + expect(value).toBe("light"); + + theme.set("dark"); + expect(value).toBe("dark"); + + unsub(); + }); +}); diff --git a/src/routes/sales/+page.svelte b/src/routes/sales/+page.svelte index fc2c5d1..b466dda 100644 --- a/src/routes/sales/+page.svelte +++ b/src/routes/sales/+page.svelte @@ -233,15 +233,25 @@ return directMap.get(statusId) ?? equivMap.get(statusId) ?? null; } - /** Determine a color class based on the resolved type flags. */ + /** Determine a color class based on the resolved type name + flags. */ function statusColorClass(op: SalesOpportunity): string { - if (op.closedFlag) return "status-closed"; + if (op.closedFlag) { + const t = resolvedType(op); + if (t?.wonFlag) return "status-won"; + if (t?.lostFlag) return "status-lost"; + return "status-closed"; + } const t = resolvedType(op); if (!t) return "status-open"; if (t.wonFlag) return "status-won"; if (t.lostFlag) return "status-lost"; if (t.closedFlag) return "status-closed"; if (t.inactiveFlag) return "status-inactive"; + const n = t.name.toLowerCase(); + if (n.includes("future")) return "status-future"; + if (n.includes("new")) return "status-new"; + if (n.includes("review")) return "status-review"; + if (n.includes("active")) return "status-active"; return "status-open"; } @@ -253,8 +263,43 @@ return op.company?.name || "—"; } - function priorityLabel(op: SalesOpportunity): string { - return op.priority?.name || "—"; + function ratingHeatClass(name: string | undefined): string { + if (!name) return "heat-neutral"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "heat-hot"; + if (n.includes("warm") || n.includes("medium")) return "heat-warm"; + if (n.includes("cold") || n.includes("cool")) return "heat-cold"; + return "heat-neutral"; + } + + function ratingHeatLevel(name: string | undefined): number { + if (!name) return 0; + const n = name.toLowerCase(); + if (n.includes("hot")) return 3; + if (n.includes("warm") || n.includes("medium")) return 2; + if (n.includes("cold") || n.includes("cool")) return 1; + return 0; + } + + function heatDotColor(name: string | undefined): string { + if (!name) return "rgba(128,128,128,0.4)"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "#ef4444"; + if (n.includes("warm") || n.includes("medium")) return "#f59e0b"; + if (n.includes("cold") || n.includes("cool")) return "#38bdf8"; + return "rgba(128,128,128,0.4)"; + } + + function getDotStyle(level: number, ratingName: string | undefined): string { + const lvl = ratingHeatLevel(ratingName); + const filled = level <= lvl; + if (!filled) + return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;"; + const color = heatDotColor(ratingName); + let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`; + if (ratingHeatClass(ratingName) === "heat-hot") + s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);"; + return s; } function getPageNumbers(current: number, total: number): (number | "...")[] { @@ -404,7 +449,7 @@ Company Stage Status - Priority + Rating Owner Expected Close Updated @@ -441,10 +486,24 @@ {statusLabel(opp)} - - - {priorityLabel(opp)} - + + {#if opp.rating?.name} + + + {#each [1, 2, 3] as level} + + {/each} + + {opp.rating.name} + + {:else} + — + {/if} {ownerLabel(opp)} diff --git a/src/routes/sales/opportunities/+page.svelte b/src/routes/sales/opportunities/+page.svelte index a195969..97d6ccc 100644 --- a/src/routes/sales/opportunities/+page.svelte +++ b/src/routes/sales/opportunities/+page.svelte @@ -233,15 +233,25 @@ return directMap.get(statusId) ?? equivMap.get(statusId) ?? null; } - /** Determine a color class based on the resolved type flags. */ + /** Determine a color class based on the resolved type name + flags. */ function statusColorClass(op: SalesOpportunity): string { - if (op.closedFlag) return "status-closed"; + if (op.closedFlag) { + const t = resolvedType(op); + if (t?.wonFlag) return "status-won"; + if (t?.lostFlag) return "status-lost"; + return "status-closed"; + } const t = resolvedType(op); if (!t) return "status-open"; if (t.wonFlag) return "status-won"; if (t.lostFlag) return "status-lost"; if (t.closedFlag) return "status-closed"; if (t.inactiveFlag) return "status-inactive"; + const n = t.name.toLowerCase(); + if (n.includes("future")) return "status-future"; + if (n.includes("new")) return "status-new"; + if (n.includes("review")) return "status-review"; + if (n.includes("active")) return "status-active"; return "status-open"; } @@ -253,8 +263,43 @@ return op.company?.name || "—"; } - function priorityLabel(op: SalesOpportunity): string { - return op.priority?.name || "—"; + function ratingHeatClass(name: string | undefined): string { + if (!name) return "heat-neutral"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "heat-hot"; + if (n.includes("warm") || n.includes("medium")) return "heat-warm"; + if (n.includes("cold") || n.includes("cool")) return "heat-cold"; + return "heat-neutral"; + } + + function ratingHeatLevel(name: string | undefined): number { + if (!name) return 0; + const n = name.toLowerCase(); + if (n.includes("hot")) return 3; + if (n.includes("warm") || n.includes("medium")) return 2; + if (n.includes("cold") || n.includes("cool")) return 1; + return 0; + } + + function heatDotColor(name: string | undefined): string { + if (!name) return "rgba(128,128,128,0.4)"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "#ef4444"; + if (n.includes("warm") || n.includes("medium")) return "#f59e0b"; + if (n.includes("cold") || n.includes("cool")) return "#38bdf8"; + return "rgba(128,128,128,0.4)"; + } + + function getDotStyle(level: number, ratingName: string | undefined): string { + const lvl = ratingHeatLevel(ratingName); + const filled = level <= lvl; + if (!filled) + return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;"; + const color = heatDotColor(ratingName); + let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`; + if (ratingHeatClass(ratingName) === "heat-hot") + s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);"; + return s; } function getPageNumbers(current: number, total: number): (number | "...")[] { @@ -404,7 +449,7 @@ Company Stage Status - Priority + Rating Owner Expected Close Updated @@ -441,10 +486,24 @@ {statusLabel(opp)} - - - {priorityLabel(opp)} - + + {#if opp.rating?.name} + + + {#each [1, 2, 3] as level} + + {/each} + + {opp.rating.name} + + {:else} + — + {/if} {ownerLabel(opp)} diff --git a/src/routes/sales/opportunity/[id]/+page.server.ts b/src/routes/sales/opportunity/[id]/+page.server.ts index a60b5f0..d39966f 100644 --- a/src/routes/sales/opportunity/[id]/+page.server.ts +++ b/src/routes/sales/opportunity/[id]/+page.server.ts @@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { notes: [], contacts: [], products: [], + quotes: [], accessToken: null, permissions: {} as PermissionMap, }; @@ -22,6 +23,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { "notes", "contacts", "products", + "quotes", ]), checkPermissions(accessToken, [ "sales.opportunity.fetch", @@ -29,6 +31,11 @@ export const load: PageServerLoad = async ({ locals, params }) => { "sales.opportunity.note.create", "sales.opportunity.note.update", "sales.opportunity.note.delete", + "sales.opportunity.quote.fetch", + "sales.opportunity.quote.commit", + "sales.opportunity.quote.preview", + "sales.opportunity.quote.download", + "sales.opportunity.quote.fetch_downloads", ]), ]); @@ -43,6 +50,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { const notes = result?.data?.notes ?? []; const contacts = result?.data?.contacts ?? []; const products = result?.data?.products ?? []; + const quotes = result?.data?.quotes ?? []; return { opportunity, @@ -50,6 +58,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { notes, contacts, products, + quotes, accessToken, permissions, }; diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte index d6b2d6d..8139920 100644 --- a/src/routes/sales/opportunity/[id]/+page.svelte +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -11,6 +11,7 @@ import ContactsTab from "./components/ContactsTab.svelte"; import ActivityTab from "./components/ActivityTab.svelte"; import ProductsTab from "./components/ProductsTab.svelte"; + import QuotesTab from "./components/QuotesTab.svelte"; export let data: PageData; @@ -19,6 +20,7 @@ $: notes = data.notes; $: contacts = data.contacts; $: products = data.products; + $: quotes = data.quotes ?? []; $: permissions = data.permissions; let localProductSequence: number[] | null = data.opportunity?.productSequence ?? null; @@ -48,6 +50,7 @@ const tabs = [ "Overview", "Products", + "Quotes", "Notes", "Contacts", "Activity", @@ -55,6 +58,11 @@ type Tab = (typeof tabs)[number]; let activeTab: Tab = "Overview"; + // Hide Quotes tab if user lacks fetch permission + $: visibleTabs = tabs.filter( + (t) => t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false + ); + // Track whether ProductsTab is in edit mode let productsEditing = false; @@ -126,7 +134,7 @@ {#if isMobile && mobileActiveTab === null}
- {#each tabs as tab} + {#each visibleTabs as tab} {/each}
@@ -293,6 +323,13 @@ on:sequenceSaved={handleSequenceSaved} on:productsChanged={handleProductsChanged} /> + {:else if activeTab === "Quotes"} + {:else if activeTab === "Notes"} import { goto } from "$app/navigation"; import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; - import { statusColorClass } from "../types"; + import { + statusColorClass, + statusLabel, + isEquivalencyStatus, + originalStatusName, + formatDate, + } from "../types"; - import { formatDate } from "../types"; + // Days until expected close + $: isClosedOpportunity = (() => { + if (!opportunity) return false; + const statusText = + `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase(); + return ( + !!opportunity.closedFlag || + !!opportunity.closedDate || + statusText.includes("won") || + statusText.includes("lost") + ); + })(); + + $: daysUntilClose = (() => { + if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null; + return Math.ceil( + (new Date(opportunity.expectedCloseDate).getTime() - Date.now()) / + (1000 * 60 * 60 * 24), + ); + })(); export let opportunity: SalesOpportunity | null; export let isMobile: boolean; @@ -44,6 +69,53 @@ if (typeof closedBy === "string") return closedBy; return closedBy.name ?? closedBy.identifier ?? String(closedBy.id ?? ""); } + + /** Map rating name to a heat tier for visual styling */ + function ratingHeatClass(name: string | undefined): string { + if (!name) return "heat-neutral"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "heat-hot"; + if (n.includes("warm") || n.includes("medium")) return "heat-warm"; + if (n.includes("cold") || n.includes("cool")) return "heat-cold"; + return "heat-neutral"; + } + + /** Map rating name to a thermometer icon fill level (0-3) */ + function ratingHeatLevel(name: string | undefined): number { + if (!name) return 0; + const n = name.toLowerCase(); + if (n.includes("hot")) return 3; + if (n.includes("warm") || n.includes("medium")) return 2; + if (n.includes("cold") || n.includes("cool")) return 1; + return 0; + } + /** Get the filled dot color for a rating heat tier */ + function heatDotColor(name: string | undefined): string { + if (!name) return "rgba(128,128,128,0.4)"; + const n = name.toLowerCase(); + if (n.includes("hot")) return "#ef4444"; + if (n.includes("warm") || n.includes("medium")) return "#f59e0b"; + if (n.includes("cold") || n.includes("cool")) return "#38bdf8"; + return "rgba(128,128,128,0.4)"; + } + /** Build the complete inline style for a heat dot */ + function getDotStyle(level: number, ratingName: string | undefined): string { + const lvl = ratingHeatLevel(ratingName); + const filled = level <= lvl; + if (!filled) + return "background: rgba(128,128,128,0.25); width: 8px; height: 8px; border-radius: 50%; display: inline-block;"; + const color = heatDotColor(ratingName); + const heat = ratingHeatClass(ratingName); + let s = `background: ${color}; width: 8px; height: 8px; border-radius: 50%; display: inline-block;`; + if (heat === "heat-hot") s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);"; + return s; + } + /** Map probability percent to a tier for styling */ + function probabilityTier(percent: number): string { + if (percent >= 75) return "prob-high"; + if (percent >= 40) return "prob-mid"; + return "prob-low"; + }
#{opportunity.cwOpportunityId} {/if} {#if opportunity.status} - - {opportunity.closedFlag ? "Closed" : opportunity.status.name} + + {statusLabel(opportunity)} {/if} {#if opportunity.type?.name} {opportunity.type.name} {/if} + {#if opportunity.type?.wonFlag || opportunity.type?.lostFlag} + + {opportunity.type.wonFlag ? "Won" : "Lost"} + + {/if} + {#if opportunity.rating?.name} + + + {#each [1, 2, 3] as level} + + {/each} + + {opportunity.rating.name} + + {/if} + {#if opportunity.probability?.percent != null} + + + + + + {opportunity.probability.percent}% + + {/if}
+ + {#if daysUntilClose !== null} +
= 0 && daysUntilClose <= 14} + > + {Math.abs(daysUntilClose)} + + {daysUntilClose < 0 + ? `day${Math.abs(daysUntilClose) !== 1 ? 's' : ''} overdue` + : `day${daysUntilClose !== 1 ? 's' : ''} to close`} + +
+ {/if} + {#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}