diff --git a/bun.lock b/bun.lock index de9541e..d2687b7 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "axios": "^1.13.3", "dotenv": "^17.2.3", "electron-squirrel-startup": "^1.0.1", - "pdfjs-dist": "^5.5.207", + "pdfjs-dist": "4.10.38", "socket.io-client": "^4.8.3", }, "devDependencies": { @@ -1061,8 +1061,6 @@ "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=="], @@ -1123,7 +1121,7 @@ "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=="], + "pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="], "pe-library": ["pe-library@1.0.1", "", {}, "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg=="], diff --git a/package.json b/package.json index 5538b3e..4d22c7d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "axios": "^1.13.3", "dotenv": "^17.2.3", "electron-squirrel-startup": "^1.0.1", - "pdfjs-dist": "^5.5.207", + "pdfjs-dist": "4.10.38", "socket.io-client": "^4.8.3" }, "trustedDependencies": [ diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte index 056f7bc..c787ccb 100644 --- a/src/components/AddProductModal.svelte +++ b/src/components/AddProductModal.svelte @@ -145,6 +145,24 @@ // ── Cart state ── let cart: CatalogItem[] = []; let showCartPreview = false; + let isQuickAdding = false; + + // ── Per-item quantity overrides ── + let itemQuantities: Record = {}; + + function getItemQty(itemId: string): number { + return itemQuantities[itemId] ?? 1; + } + + function setItemQty(itemId: string, qty: number) { + const clamped = Math.max(1, Math.floor(qty) || 1); + itemQuantities[itemId] = clamped; + itemQuantities = itemQuantities; // trigger reactivity + } + + function applyQuantity(item: CatalogItem): CatalogItem { + return { ...item, quantity: getItemQty(item.id) }; + } // ── Error state ── let errorMessage = ""; @@ -560,14 +578,19 @@ ? cart.some((c) => c.id === detailItem?.id) : false; - function quickAdd(item: CatalogItem) { - onSelect(item); - handleClose(); + async function quickAdd(item: CatalogItem) { + isQuickAdding = true; + try { + await Promise.resolve(onSelect(applyQuantity(item))); + handleClose(); + } finally { + isQuickAdding = false; + } } - function handleAddSelected() { + async function handleAddSelected() { if (cart.length === 0) return; - onSelect([...cart]); + await Promise.resolve(onSelect(cart.map(applyQuantity))); handleClose(); } @@ -709,6 +732,8 @@ detailPaneMode = "item"; cart = []; showCartPreview = false; + isQuickAdding = false; + itemQuantities = {}; errorMessage = ""; filterSubcategories = []; filterManufacturers = []; @@ -1052,7 +1077,12 @@ {#each cart.slice(0, 5) as item}
{item.description || item.identifier || "Item"}{#if getItemQty(item.id) > 1}{getItemQty(item.id)}×{/if}{item.description || + item.identifier || + "Item"} {#if item.price != null} +
+ + + setItemQty( + item.id, + parseInt(e.currentTarget.value, 10), + )} + /> + +
+
+ +
+ + { + if (detailItem) + setItemQty( + detailItem.id, + parseInt(e.currentTarget.value, 10), + ); + }} + /> + +
+
{#if detailItemInCart}
{/if} @@ -3042,6 +3161,13 @@ min-width: 0; } + .cart-preview-qty { + font-weight: 700; + color: var(--accent, #6366f1); + margin-right: 3px; + font-size: 11px; + } + .cart-preview-item-price { font-size: 11px; color: var(--text-muted); @@ -4337,6 +4463,80 @@ padding: 16px; } + .detail-pane-qty { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + } + + .detail-qty-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + min-width: 24px; + } + + .detail-qty-controls { + display: flex; + align-items: center; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; + } + + .detail-qty-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: var(--bg-surface, rgba(255, 255, 255, 0.04)); + color: var(--text-secondary); + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: + background 0.12s, + color 0.12s; + user-select: none; + } + + .detail-qty-btn:hover:not(:disabled) { + background: var(--bg-hover, rgba(255, 255, 255, 0.08)); + color: var(--text-primary); + } + + .detail-qty-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .detail-qty-input { + width: 60px; + height: 32px; + border: none; + border-left: 1px solid var(--border-subtle); + border-right: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + text-align: center; + -moz-appearance: textfield; + appearance: textfield; + } + + .detail-qty-input::-webkit-inner-spin-button, + .detail-qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + .detail-pane-actions { display: flex; flex-direction: column; @@ -4494,6 +4694,27 @@ color: #eab308; } + .btn-quick-add:disabled { + opacity: 0.65; + cursor: not-allowed; + } + + .btn-quick-add:disabled:hover { + background: transparent; + border-color: var(--border-subtle); + color: var(--text-secondary); + } + + @keyframes quick-add-spin { + to { + transform: rotate(360deg); + } + } + + .quick-add-spinner { + animation: quick-add-spin 0.8s linear infinite; + } + /* ── Cart pane items ── */ .cart-pane-item { display: flex; @@ -4577,6 +4798,67 @@ border-color: rgba(220, 38, 38, 0.2); } + .cart-pane-item-qty { + display: flex; + align-items: center; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + } + + .cart-qty-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: var(--bg-surface, rgba(255, 255, 255, 0.04)); + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.12s, + color 0.12s; + user-select: none; + padding: 0; + } + + .cart-qty-btn:hover:not(:disabled) { + background: var(--bg-hover, rgba(255, 255, 255, 0.08)); + color: var(--text-primary); + } + + .cart-qty-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .cart-qty-input { + width: 44px; + height: 24px; + border: none; + border-left: 1px solid var(--border-subtle); + border-right: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-primary); + font-size: 11px; + font-weight: 600; + text-align: center; + -moz-appearance: textfield; + appearance: textfield; + padding: 0; + } + + .cart-qty-input::-webkit-inner-spin-button, + .cart-qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + .empty-cart-message { display: flex; flex-direction: column; diff --git a/src/components/CreateOpportunityModal.svelte b/src/components/CreateOpportunityModal.svelte index 6583688..a25e4ed 100644 --- a/src/components/CreateOpportunityModal.svelte +++ b/src/components/CreateOpportunityModal.svelte @@ -96,6 +96,10 @@ const res = await fetch("/api/cw/members"); const json = await res.json(); members = json.data ?? []; + // Auto-select first member as primary sales rep if not already set + if (!primarySalesRepId && members.length > 0) { + primarySalesRepId = String(members[0].id); + } } catch (err) { console.error("Failed to load members:", err); } finally { diff --git a/src/components/CreateRoleModal.svelte b/src/components/CreateRoleModal.svelte index 7802c4f..8c8d446 100644 --- a/src/components/CreateRoleModal.svelte +++ b/src/components/CreateRoleModal.svelte @@ -15,6 +15,43 @@ $: isEditMode = roleToEdit !== null; + /** Recursively collect all permission node strings from a category tree, + * including field-level permission strings attached to each node. */ + function collectAllNodes(cat: PermissionCategory): string[] { + const nodes: string[] = []; + for (const p of cat.permissions ?? []) { + nodes.push(p.node); + if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions); + } + if (cat.subCategories) { + for (const sub of Object.values(cat.subCategories)) { + nodes.push(...collectAllNodes(sub as PermissionCategory)); + } + } + return nodes; + } + + /** Recursively collect all PermissionNode objects from a category tree. */ + function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] { + const nodes = [...(cat.permissions ?? [])]; + if (cat.subCategories) { + for (const sub of Object.values(cat.subCategories)) { + nodes.push(...collectAllPermNodes(sub as PermissionCategory)); + } + } + return nodes; + } + + /** Collect all selectable strings (node + fieldLevelPermissions) from PermissionNode[]. */ + function allSelectableStrings(perms: PermissionNode[]): string[] { + const out: string[] = []; + for (const p of perms) { + out.push(p.node); + if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions); + } + return out; + } + $: if (isOpen && roleToEdit) { title = roleToEdit.title; moniker = roleToEdit.moniker; @@ -24,7 +61,7 @@ // Identify permissions not in the categorized list → show as custom const catalogued = new Set( Object.values(permissionNodes).flatMap((cat) => - (cat as PermissionCategory).permissions.map((p) => p.node), + collectAllNodes(cat as PermissionCategory), ), ); customNodes = roleToEdit.permissions.filter((p) => !catalogued.has(p)); @@ -36,9 +73,21 @@ string, PermissionCategory, ][]) { - if (cat.permissions.some((p) => sel.has(p.node))) { + const allNodes = collectAllNodes(cat); + if (allNodes.some((n) => sel.has(n))) { expanded.add(key); } + // Also auto-expand subcategories that contain selected permissions + if (cat.subCategories) { + for (const [subKey, subCat] of Object.entries(cat.subCategories) as [ + string, + PermissionCategory, + ][]) { + if (collectAllNodes(subCat).some((n) => sel.has(n))) { + expanded.add(`${key}/${subKey}`); + } + } + } } expandedCategories = expanded; } @@ -83,10 +132,16 @@ function toggleAllInCategory(perms: PermissionNode[]) { const next = new Set(selectedPermissions); - const allSelected = perms.every((p) => next.has(p.node)); - perms.forEach((p) => - allSelected ? next.delete(p.node) : next.add(p.node), - ); + const all = allSelectableStrings(perms); + const allSelected = all.every((s) => next.has(s)); + all.forEach((s) => (allSelected ? next.delete(s) : next.add(s))); + selectedPermissions = next; + } + + function toggleFieldLevelPerms(fieldPerms: string[]) { + const next = new Set(selectedPermissions); + const allSelected = fieldPerms.every((f) => next.has(f)); + fieldPerms.forEach((f) => (allSelected ? next.delete(f) : next.add(f))); selectedPermissions = next; } @@ -114,7 +169,7 @@ } // Add to custom list if not from the categorized set const catalogued = Object.values(permissionNodes).flatMap((cat) => - (cat as PermissionCategory).permissions.map((p) => p.node), + collectAllNodes(cat as PermissionCategory), ); if (!catalogued.includes(node)) { customNodes = [...customNodes, node]; @@ -300,13 +355,19 @@ {:else} {#each permissionEntries as [catKey, category] (catKey)} {@const catPerms = category.permissions ?? []} + {@const allCatPermNodes = collectAllPermNodes(category)} + {@const allCatSelectables = + allSelectableStrings(allCatPermNodes)} {@const allSel = - catPerms.length > 0 && - catPerms.every((p) => selectedPermissions.has(p.node))} + allCatSelectables.length > 0 && + allCatSelectables.every((s) => selectedPermissions.has(s))} {@const someSel = !allSel && - catPerms.some((p) => selectedPermissions.has(p.node))} + allCatSelectables.some((s) => selectedPermissions.has(s))} {@const isExpanded = expandedCategories.has(catKey)} + {@const hasSubCats = + category.subCategories && + Object.keys(category.subCategories).length > 0}
{#if isExpanded} -
- {#each catPerms as perm (perm.node)} - {@const sel = selectedPermissions.has(perm.node)} -
{/each} @@ -945,6 +1434,58 @@ border-top: 1px solid var(--border-subtle); } + /* ── Subcategory groups (e.g. Object Type field-level permissions) ── */ + .sub-cat-list { + border-top: 1px solid var(--border-subtle); + padding: 0; + } + + .sub-cat-list-no-direct { + border-top: 1px solid var(--border-subtle); + } + + .sub-cat-group { + border-bottom: 1px solid var(--border-subtle); + } + + .sub-cat-group:last-child { + border-bottom: none; + } + + .sub-cat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 10px; + } + + .sub-cat-toggle { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + padding: 6px 10px 6px 22px; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s; + } + + .sub-cat-toggle:hover { + background: var(--card-hover-bg); + } + + .sub-cat-name { + font-size: 11.5px; + font-weight: 600; + color: var(--text-secondary); + } + + .sub-cat-perms { + padding-left: 12px; + } + .perm-row { display: flex; align-items: flex-start; @@ -988,6 +1529,70 @@ line-height: 1.4; } + /* ── Field-level permissions ── */ + .field-expand-btn { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + padding: 2px 6px; + background: none; + border: 1px solid var(--border-subtle); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: + background 0.1s, + border-color 0.1s; + } + + .field-expand-btn:hover { + background: var(--card-hover-bg); + border-color: var(--border-default); + } + + .field-expand-count { + font-size: 10px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + } + + .field-perms-wrap { + margin: 0 0 4px; + padding: 4px 0 4px 40px; + border-left: 2px solid var(--border-subtle); + margin-left: 32px; + } + + .sub-field-perms-wrap { + margin-left: 32px; + } + + .field-perms-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 8px 4px 4px; + } + + .field-perms-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + } + + .field-perm-row { + padding: 3px 8px 3px 4px; + } + + .field-perm-node { + font-size: 10.5px; + color: var(--text-secondary); + } + .sr-only { position: absolute; width: 1px; diff --git a/src/components/EditUserModal.svelte b/src/components/EditUserModal.svelte index fa4f33e..07ed48e 100644 --- a/src/components/EditUserModal.svelte +++ b/src/components/EditUserModal.svelte @@ -3,7 +3,11 @@ import type { SubmitFunction } from "@sveltejs/kit"; import type { User } from "$lib/optima-api/modules/users"; import type { Role } from "$lib/optima-api/modules/roles"; - import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions"; + import type { + PermissionsCategorized, + PermissionCategory, + PermissionNode, + } from "$lib/optima-api/modules/permissions"; type UserWithRoles = User & { roleDetails: Role[] }; @@ -32,23 +36,124 @@ // Track custom-added nodes so they appear in the list let customNodes: string[] = []; - $: allPermissionNodes = [ - ...Object.values(permissionNodes ?? {}).flatMap((cat) => - cat.permissions.map((p) => p.node), - ), - ...customNodes.filter( - (n) => - !Object.values(permissionNodes ?? {}) - .flatMap((cat) => cat.permissions.map((p) => p.node)) - .includes(n), - ), + // Expanded category state for the hierarchical permission view + let expandedCategories = new Set(); + + /** Recursively collect all permission node strings from a category tree, + * including field-level permission strings attached to each node. */ + function collectAllNodes(cat: PermissionCategory): string[] { + const nodes: string[] = []; + for (const p of cat.permissions ?? []) { + nodes.push(p.node); + if (p.fieldLevelPermissions) nodes.push(...p.fieldLevelPermissions); + } + if (cat.subCategories) { + for (const sub of Object.values(cat.subCategories)) { + nodes.push(...collectAllNodes(sub as PermissionCategory)); + } + } + return nodes; + } + + /** Recursively collect all PermissionNode objects from a category tree. */ + function collectAllPermNodes(cat: PermissionCategory): PermissionNode[] { + const nodes = [...(cat.permissions ?? [])]; + if (cat.subCategories) { + for (const sub of Object.values(cat.subCategories)) { + nodes.push(...collectAllPermNodes(sub as PermissionCategory)); + } + } + return nodes; + } + + /** Collect all selectable strings (node + fieldLevelPermissions) from PermissionNode[]. */ + function allSelectableStrings(perms: PermissionNode[]): string[] { + const out: string[] = []; + for (const p of perms) { + out.push(p.node); + if (p.fieldLevelPermissions) out.push(...p.fieldLevelPermissions); + } + return out; + } + + $: allPermissionNodeStrings = Object.values(permissionNodes ?? {}).flatMap( + (cat) => collectAllNodes(cat as PermissionCategory), + ); + + $: allPermissionNodesWithCustom = [ + ...allPermissionNodeStrings, + ...customNodes.filter((n) => !allPermissionNodeStrings.includes(n)), ]; - $: filteredPermNodes = permSearchQuery.trim() - ? allPermissionNodes.filter((n) => - n.toLowerCase().includes(permSearchQuery.toLowerCase()), - ) - : allPermissionNodes; + $: permissionEntries = Object.entries(permissionNodes ?? {}) as [ + string, + PermissionCategory, + ][]; + + // Initialize expanded categories to those containing selected permissions + $: { + const sel = new Set(editSelectedPermissions); + if (sel.size > 0 && expandedCategories.size === 0) { + const expanded = new Set(); + for (const [key, cat] of permissionEntries) { + if (collectAllNodes(cat).some((n) => sel.has(n))) { + expanded.add(key); + } + if (cat.subCategories) { + for (const [subKey, subCat] of Object.entries(cat.subCategories) as [ + string, + PermissionCategory, + ][]) { + if (collectAllNodes(subCat).some((n) => sel.has(n))) { + expanded.add(`${key}/${subKey}`); + } + } + } + } + expandedCategories = expanded; + } + } + + function toggleCategory(key: string) { + const next = new Set(expandedCategories); + next.has(key) ? next.delete(key) : next.add(key); + expandedCategories = next; + } + + function toggleAllInCategory(perms: PermissionNode[]) { + const all = allSelectableStrings(perms); + const allSelected = all.every((s) => editSelectedPermissions.includes(s)); + if (allSelected) { + const removeSet = new Set(all); + editSelectedPermissions = editSelectedPermissions.filter( + (p) => !removeSet.has(p), + ); + } else { + const existing = new Set(editSelectedPermissions); + for (const s of all) { + existing.add(s); + } + editSelectedPermissions = [...existing]; + } + } + + function toggleFieldLevelPerms(fieldPerms: string[]) { + const allSelected = fieldPerms.every((f) => + editSelectedPermissions.includes(f), + ); + if (allSelected) { + const removeSet = new Set(fieldPerms); + editSelectedPermissions = editSelectedPermissions.filter( + (p) => !removeSet.has(p), + ); + } else { + const existing = new Set(editSelectedPermissions); + for (const f of fieldPerms) { + existing.add(f); + } + editSelectedPermissions = [...existing]; + } + } function toggleEditRole(id: string) { if (editSelectedRoles.includes(id)) { @@ -90,7 +195,7 @@ customPermError = "This permission is already selected."; return; } - if (!allPermissionNodes.includes(node)) { + if (!allPermissionNodesWithCustom.includes(node)) { customNodes = [...customNodes, node]; } editSelectedPermissions = [...editSelectedPermissions, node]; @@ -245,14 +350,8 @@ >{editSelectedPermissions.length} selected
-
- -
+ +
{customPermError}

{/if} + +
- {#each filteredPermNodes as node} - - {/each} - {#if filteredPermNodes.length === 0} -

No permissions match your search.

+ {#if permissionEntries.length === 0} +

No permission data available.

+ {:else} + {#each permissionEntries as [catKey, category] (catKey)} + {@const catPerms = category.permissions ?? []} + {@const allCatPermNodes = collectAllPermNodes(category)} + {@const allCatSelectables = + allSelectableStrings(allCatPermNodes)} + {@const allSel = + allCatSelectables.length > 0 && + allCatSelectables.every((s) => + editSelectedPermissions.includes(s), + )} + {@const someSel = + !allSel && + allCatSelectables.some((s) => + editSelectedPermissions.includes(s), + )} + {@const isExpanded = expandedCategories.has(catKey)} + {@const hasSubCats = + category.subCategories && + Object.keys(category.subCategories).length > 0} +
+
+ + +
+ + {#if isExpanded} + + {#if catPerms.length > 0} +
+ {#each catPerms as perm (perm.node)} + {@const sel = editSelectedPermissions.includes( + perm.node, + )} + {@const hasFields = + perm.fieldLevelPermissions && + perm.fieldLevelPermissions.length > 0} + {@const fieldsExpanded = + hasFields && + expandedCategories.has(`field:${perm.node}`)} + + {#if hasFields && fieldsExpanded} + {@const fieldAllSel = + perm.fieldLevelPermissions?.every((f) => + editSelectedPermissions.includes(f), + ) ?? false} + {@const fieldSomeSel = + !fieldAllSel && + (perm.fieldLevelPermissions?.some((f) => + editSelectedPermissions.includes(f), + ) ?? + false)} +
+
+ Field Permissions + +
+ {#each perm.fieldLevelPermissions ?? [] as fieldPerm (fieldPerm)} + {@const fSel = + editSelectedPermissions.includes(fieldPerm)} + + {/each} +
+ {/if} + {/each} +
+ {/if} + + + {#if hasSubCats} +
+ {#each Object.entries(category.subCategories ?? {}) as [subKey, subCat] (subKey)} + {@const subCategory = subCat} + {@const subPerms = subCategory.permissions ?? []} + {@const subAllNodes = + collectAllPermNodes(subCategory)} + {@const subAllSelectables = + allSelectableStrings(subAllNodes)} + {@const subAllSel = + subAllSelectables.length > 0 && + subAllSelectables.every((s) => + editSelectedPermissions.includes(s), + )} + {@const subSomeSel = + !subAllSel && + subAllSelectables.some((s) => + editSelectedPermissions.includes(s), + )} + {@const subExpanded = expandedCategories.has( + `${catKey}/${subKey}`, + )} +
+
+ + +
+ + {#if subExpanded} +
+ {#each subPerms as perm (perm.node)} + {@const sel = + editSelectedPermissions.includes(perm.node)} + {@const hasFields = + perm.fieldLevelPermissions && + perm.fieldLevelPermissions.length > 0} + {@const fieldsExpanded = + hasFields && + expandedCategories.has( + `field:${perm.node}`, + )} + + {#if hasFields && fieldsExpanded} + {@const fieldAllSel = + perm.fieldLevelPermissions?.every((f) => + editSelectedPermissions.includes(f), + ) ?? false} + {@const fieldSomeSel = + !fieldAllSel && + (perm.fieldLevelPermissions?.some((f) => + editSelectedPermissions.includes(f), + ) ?? + false)} +
+
+ Field Permissions + +
+ {#each perm.fieldLevelPermissions ?? [] as fieldPerm (fieldPerm)} + {@const fSel = + editSelectedPermissions.includes( + fieldPerm, + )} + + {/each} +
+ {/if} + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
+ {/each} + {/if} + + + {#if customNodes.length > 0} +
+
+
+ + + + + Custom + {customNodes.length} +
+
+
+ {#each customNodes as node (node)} + {@const sel = editSelectedPermissions.includes(node)} + + {/each} +
+
{/if}
@@ -632,79 +1238,308 @@ color: #dc2626; } - /* ── Permission list ── */ - .edit-perm-search-wrap { - margin-bottom: 8px; - } - - .edit-perm-search { - width: 100%; - padding: 6px 10px; + /* ── Categorized permission list ── */ + .edit-perm-list { border: 1px solid var(--border-subtle); border-radius: 6px; - background: var(--bg-inset); - font-size: 12.5px; - color: var(--text-primary); - transition: - border-color 0.15s, - box-shadow 0.15s; - } - - .edit-perm-search::placeholder { - color: var(--text-muted); - } - - .edit-perm-search:focus { - outline: none; - border-color: var(--input-focus-border); - box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); - } - - .edit-perm-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 180px; overflow-y: auto; - padding-right: 4px; - } - - .edit-perm-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - border-radius: 5px; - cursor: pointer; - transition: background 0.1s; - } - - .edit-perm-item:hover { - background: var(--card-hover-bg); - } - - .edit-perm-item input[type="checkbox"] { - accent-color: var(--accent-color, #0066cc); - width: 14px; - height: 14px; - flex-shrink: 0; - cursor: pointer; - } - - .edit-perm-node { - font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace; - font-size: 11.5px; - color: var(--text-primary); + max-height: 260px; + background: var(--bg-inset); } .edit-perm-empty { margin: 0; - padding: 8px; + padding: 12px; font-size: 12.5px; color: var(--text-muted); text-align: center; } + /* ── Category groups ── */ + .edit-cat-group { + border-bottom: 1px solid var(--border-subtle); + } + + .edit-cat-group:last-child { + border-bottom: none; + } + + .edit-cat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 8px; + } + + .edit-cat-toggle { + display: flex; + align-items: center; + gap: 5px; + flex: 1; + padding: 6px 8px; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s; + } + + .edit-cat-toggle:hover { + background: var(--card-hover-bg); + } + + .edit-custom-cat-label { + cursor: default; + display: flex; + align-items: center; + gap: 5px; + padding: 6px 8px; + } + + .edit-custom-cat-label svg { + color: var(--text-muted); + flex-shrink: 0; + } + + .chevron { + color: var(--text-muted); + flex-shrink: 0; + transition: transform 0.15s ease; + } + + .chevron.rotated { + transform: rotate(90deg); + } + + .edit-cat-name { + font-size: 11.5px; + font-weight: 600; + color: var(--text-primary); + } + + .edit-cat-count { + font-size: 10px; + color: var(--text-muted); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + padding: 0 5px; + border-radius: 7px; + } + + .edit-cat-all-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: none; + border: 1px solid var(--border-subtle); + border-radius: 4px; + cursor: pointer; + font-size: 10px; + font-weight: 500; + color: var(--text-secondary); + flex-shrink: 0; + transition: + background 0.1s, + border-color 0.1s; + } + + .edit-cat-all-btn:hover { + background: var(--card-hover-bg); + border-color: var(--border-default); + } + + /* ── Custom checkbox ── */ + .edit-cb { + width: 12px; + height: 12px; + border: 1.5px solid var(--border-default); + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: var(--bg-surface); + color: #fff; + transition: + background 0.1s, + border-color 0.1s; + } + + .edit-cb.edit-cb-checked { + background: var(--accent-color, #0066cc); + border-color: var(--accent-color, #0066cc); + } + + .edit-cb.edit-cb-indeterminate { + background: var(--bg-surface); + border-color: var(--accent-color, #0066cc); + color: var(--accent-color, #0066cc); + } + + /* ── Permission rows ── */ + .edit-cat-perms { + padding: 2px 0 4px; + border-top: 1px solid var(--border-subtle); + } + + .edit-perm-row { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 4px 8px 4px 20px; + cursor: pointer; + transition: background 0.1s; + } + + .edit-perm-row:hover { + background: var(--card-hover-bg); + } + + .edit-perm-row.edit-perm-sel { + background: rgba(0, 102, 204, 0.05); + } + + .edit-perm-row input[type="checkbox"] { + accent-color: var(--accent-color, #0066cc); + width: 13px; + height: 13px; + flex-shrink: 0; + cursor: pointer; + margin-top: 1px; + } + + .edit-perm-info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + } + + .edit-perm-node { + font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace; + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + } + + .edit-perm-desc { + font-size: 10px; + color: var(--text-muted); + line-height: 1.3; + } + + /* ── Subcategories ── */ + .edit-sub-cat-list { + border-top: 1px solid var(--border-subtle); + } + + .edit-sub-cat-list-no-direct { + border-top: 1px solid var(--border-subtle); + } + + .edit-sub-cat-group { + border-bottom: 1px solid var(--border-subtle); + } + + .edit-sub-cat-group:last-child { + border-bottom: none; + } + + .edit-sub-cat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 8px; + } + + .edit-sub-cat-toggle { + display: flex; + align-items: center; + gap: 5px; + flex: 1; + padding: 5px 8px 5px 18px; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s; + } + + .edit-sub-cat-toggle:hover { + background: var(--card-hover-bg); + } + + .edit-sub-cat-name { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + } + + .edit-sub-cat-perms { + padding-left: 10px; + } + + /* ── Field-level permissions ── */ + .edit-field-expand-btn { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + padding: 1px 5px; + background: none; + border: 1px solid var(--border-subtle); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: + background 0.1s, + border-color 0.1s; + } + + .edit-field-expand-btn:hover { + background: var(--card-hover-bg); + border-color: var(--border-default); + } + + .edit-field-expand-count { + font-size: 9.5px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + } + + .edit-field-perms-wrap { + margin: 0 0 4px; + padding: 3px 0 3px 32px; + border-left: 2px solid var(--border-subtle); + margin-left: 26px; + } + + .edit-field-perms-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px 3px 4px; + } + + .edit-field-perms-label { + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + } + + .edit-field-perm-row { + padding: 2px 6px 2px 4px; + } + + .edit-field-perm-node { + font-size: 10px; + color: var(--text-secondary); + } + /* Scrollbars */ .edit-role-list::-webkit-scrollbar, .edit-perm-list::-webkit-scrollbar { diff --git a/src/components/ErrorBoundary.svelte.test.ts b/src/components/ErrorBoundary.svelte.test.ts new file mode 100644 index 0000000..1037e90 --- /dev/null +++ b/src/components/ErrorBoundary.svelte.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/svelte"; +import ErrorBoundary from "./ErrorBoundary.svelte"; + +describe("ErrorBoundary", () => { + it("renders with default title and message", () => { + render(ErrorBoundary); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect( + screen.getByText("An unexpected error occurred"), + ).toBeInTheDocument(); + }); + + it("renders with custom title and message", () => { + render(ErrorBoundary, { + props: { + title: "Custom Error", + message: "Something specific happened", + }, + }); + + expect(screen.getByText("Custom Error")).toBeInTheDocument(); + expect(screen.getByText("Something specific happened")).toBeInTheDocument(); + }); + + it("renders error icon", () => { + const { container } = render(ErrorBoundary); + + expect(container.querySelector(".error-icon")).toBeInTheDocument(); + }); + + it("renders Retry button and Go Home link", () => { + render(ErrorBoundary); + + expect(screen.getByText("Retry")).toBeInTheDocument(); + expect(screen.getByText("Go Home")).toBeInTheDocument(); + }); + + it("shows details toggle when details are provided", () => { + render(ErrorBoundary, { + props: { + details: { error: "stack trace" }, + }, + }); + + expect(screen.getByText("Show Error Details")).toBeInTheDocument(); + }); + + it("does not show details toggle when no details", () => { + render(ErrorBoundary); + + expect(screen.queryByText("Show Error Details")).not.toBeInTheDocument(); + }); + + it("toggles error details on click", async () => { + render(ErrorBoundary, { + props: { + details: { error: "stack trace" }, + }, + }); + + const toggle = screen.getByText("Show Error Details"); + await fireEvent.click(toggle); + + expect(screen.getByText("Hide Error Details")).toBeInTheDocument(); + expect(screen.getByText("Error Information")).toBeInTheDocument(); + }); + + it("Go Home link points to /", () => { + render(ErrorBoundary); + + const homeLink = screen.getByText("Go Home"); + expect(homeLink).toHaveAttribute("href", "/"); + }); + + it("logs error details on mount", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(ErrorBoundary, { + props: { + details: { key: "value" }, + }, + }); + + expect(consoleSpy).toHaveBeenCalledWith("Error details:", { + key: "value", + }); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/components/LoadingSpinner.svelte.test.ts b/src/components/LoadingSpinner.svelte.test.ts new file mode 100644 index 0000000..9bfc386 --- /dev/null +++ b/src/components/LoadingSpinner.svelte.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import LoadingSpinner from "./LoadingSpinner.svelte"; + +describe("LoadingSpinner", () => { + it("renders overlay when loading is true", () => { + render(LoadingSpinner, { props: { loading: true } }); + + const overlay = screen.getByRole("status"); + expect(overlay).toBeInTheDocument(); + }); + + it("does not render when loading is false", () => { + render(LoadingSpinner, { props: { loading: false } }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("contains a spinner element", () => { + render(LoadingSpinner, { props: { loading: true } }); + + const overlay = screen.getByRole("status"); + const spinner = overlay.querySelector(".spinner"); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/src/components/NoResultsMonkey.svelte.test.ts b/src/components/NoResultsMonkey.svelte.test.ts new file mode 100644 index 0000000..5cc4ee5 --- /dev/null +++ b/src/components/NoResultsMonkey.svelte.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import NoResultsMonkey from "./NoResultsMonkey.svelte"; + +describe("NoResultsMonkey", () => { + it("renders with default message", () => { + render(NoResultsMonkey); + + expect(screen.getByText("No results found")).toBeInTheDocument(); + }); + + it("renders with custom message", () => { + render(NoResultsMonkey, { props: { message: "Nothing here" } }); + + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); + + it("renders SVG illustration", () => { + const { container } = render(NoResultsMonkey); + + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("applies custom size", () => { + const { container } = render(NoResultsMonkey, { + props: { size: 200 }, + }); + + const wrapper = container.querySelector(".monkey"); + expect(wrapper).toHaveStyle("width: 200px"); + }); +}); diff --git a/src/components/PdfViewer.svelte b/src/components/PdfViewer.svelte index 5ae71bd..6e0e41b 100644 --- a/src/components/PdfViewer.svelte +++ b/src/components/PdfViewer.svelte @@ -48,7 +48,10 @@ await initPdfJs(); if (!pdfjsLib || generation !== renderGeneration) return; - const pdf = await pdfjsLib.getDocument({ url: pdfSrc, isEvalSupported: false }).promise; + const pdf = await pdfjsLib.getDocument({ + url: pdfSrc, + isEvalSupported: false, + }).promise; if (generation !== renderGeneration) { pdf.destroy(); return; @@ -64,7 +67,8 @@ // 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 safeScale = + maxDim > MAX_CANVAS_DIM ? MAX_CANVAS_DIM / maxDim : 1.0; const viewport = page.getViewport({ scale: safeScale }); const canvas = document.createElement("canvas"); @@ -74,7 +78,8 @@ canvas.addEventListener("contextmenu", (e) => e.preventDefault()); container.appendChild(canvas); - await page.render({ canvas, viewport } as any).promise; + const canvasContext = canvas.getContext("2d")!; + await page.render({ canvasContext, viewport }).promise; // Release page resources after rendering page.cleanup(); @@ -115,11 +120,7 @@ }); -
+
{#if loading}

Rendering PDF...

diff --git a/src/components/ResultsSpinner.svelte.test.ts b/src/components/ResultsSpinner.svelte.test.ts new file mode 100644 index 0000000..6b1dbd8 --- /dev/null +++ b/src/components/ResultsSpinner.svelte.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/svelte"; +import ResultsSpinner from "./ResultsSpinner.svelte"; + +describe("ResultsSpinner", () => { + it("renders with default props", () => { + const { container } = render(ResultsSpinner); + + const wrapper = container.querySelector(".rs"); + expect(wrapper).toBeInTheDocument(); + expect(wrapper).toHaveStyle("width: 36px"); + expect(wrapper).toHaveStyle("height: 36px"); + }); + + it("renders with custom size", () => { + const { container } = render(ResultsSpinner, { + props: { size: 48 }, + }); + + const wrapper = container.querySelector(".rs"); + expect(wrapper).toHaveStyle("width: 48px"); + expect(wrapper).toHaveStyle("height: 48px"); + }); + + it("renders with custom color", () => { + const { container } = render(ResultsSpinner, { + props: { color: "#ff0000" }, + }); + + const arc = container.querySelector(".arc"); + expect(arc).toHaveAttribute("stroke", "#ff0000"); + }); + + it("has aria-hidden attribute", () => { + const { container } = render(ResultsSpinner); + + const wrapper = container.querySelector(".rs"); + expect(wrapper).toHaveAttribute("aria-hidden", "true"); + }); + + it("contains SVG with circles and path", () => { + const { container } = render(ResultsSpinner); + + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.querySelector("circle.bg")).toBeInTheDocument(); + expect(container.querySelector("path.arc")).toBeInTheDocument(); + }); +}); diff --git a/src/hooks.server.spec.ts b/src/hooks.server.spec.ts new file mode 100644 index 0000000..2a85cd9 --- /dev/null +++ b/src/hooks.server.spec.ts @@ -0,0 +1,423 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockApi, mockOptima, mockIsInvalidSignatureError, mockRedirect } = + vi.hoisted(() => ({ + mockApi: { + get: vi.fn(), + }, + mockOptima: { + auth: {}, + user: { + isLoggedIn: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), + fetchInfo: vi.fn(), + checkPermissions: vi.fn(), + }, + }, + mockIsInvalidSignatureError: vi.fn(), + mockRedirect: vi.fn(), + })); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/optima-api/axios", () => ({ default: mockApi, api: mockApi })); +vi.mock("$lib/optima-api/errorHandler", () => ({ + isInvalidSignatureError: mockIsInvalidSignatureError, +})); +vi.mock("@sveltejs/kit", () => ({ + redirect: mockRedirect, +})); + +import { handle } from "./hooks.server"; + +function createMockEvent(overrides: Record = {}) { + const cookies: Record = {}; + return { + url: new URL("http://localhost/"), + cookies: { + get: vi.fn((name: string) => cookies[name] ?? null), + set: vi.fn((name: string, value: string) => { + cookies[name] = value; + }), + delete: vi.fn((name: string) => { + delete cookies[name]; + }), + }, + locals: {} as Record, + ...overrides, + }; +} + +function createResolve(response = new Response("OK")) { + return vi.fn().mockResolvedValue(response); +} + +describe("hooks.server handle", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsInvalidSignatureError.mockReturnValue(false); + }); + + // ── Health check bypass ─────────────────────────────────────────── + + it("passes /healthz through without API check", async () => { + const event = createMockEvent({ + url: new URL("http://localhost/healthz"), + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(mockApi.get).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith(event); + }); + + // ── API health check ───────────────────────────────────────────── + + it("returns 503 page when API teapot check fails", async () => { + mockApi.get.mockRejectedValueOnce(new Error("Network error")); + const event = createMockEvent(); + const resolve = createResolve(); + + const response = await handle({ event, resolve } as any); + + expect(response.status).toBe(503); + const html = await response.text(); + expect(html).toContain("Unable to Reach API"); + }); + + it("returns 503 when API teapot returns non-418 status", async () => { + mockApi.get.mockResolvedValueOnce({ status: 200 }); + const event = createMockEvent(); + const resolve = createResolve(); + + const response = await handle({ event, resolve } as any); + + expect(response.status).toBe(503); + }); + + // ── Logout path ────────────────────────────────────────────────── + + it("clears cookies and redirects on /logout", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + const event = createMockEvent({ + url: new URL("http://localhost/logout"), + }); + event.cookies.get = vi + .fn() + .mockReturnValueOnce("access-tok") + .mockReturnValueOnce("refresh-tok"); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(event.cookies.delete).toHaveBeenCalledWith("accessToken", { + path: "/", + }); + expect(event.cookies.delete).toHaveBeenCalledWith("refreshToken", { + path: "/", + }); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + }); + + // ── Login path when already logged in ───────────────────────────── + + it("redirects to / when visiting /login while already logged in", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockOptima.user.isLoggedIn.mockReturnValue(true); + const event = createMockEvent({ + url: new URL("http://localhost/login"), + }); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(mockRedirect).toHaveBeenCalledWith(303, "/"); + }); + + // ── Login path when not logged in ───────────────────────────────── + + it("resolves /login normally when user is not logged in", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockOptima.user.isLoggedIn.mockReturnValue(false); + const event = createMockEvent({ + url: new URL("http://localhost/login"), + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(resolve).toHaveBeenCalledWith(event); + }); + + // ── No tokens — force logout ────────────────────────────────────── + + it("forces logout when no access or refresh tokens exist", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn().mockReturnValue(null); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(mockOptima.user.logout).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + }); + + // ── Valid access token — normal flow ────────────────────────────── + + it("resolves normally with valid non-expired access token", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + // Create a JWT payload with exp far in the future + const payload = { + exp: Math.floor(Date.now() / 1000) + 3600, + sub: "user-1", + }; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + "base64url", + ); + const fakeJwt = `header.${encodedPayload}.signature`; + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return fakeJwt; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(resolve).toHaveBeenCalledWith(event); + // setTokens should have set session on locals + expect(event.locals.session).toBeDefined(); + }); + + // ── Expired access token — refresh ──────────────────────────────── + + it("refreshes expired access token using refresh token", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + // Create an expired JWT + const payload = { exp: Math.floor(Date.now() / 1000) - 100, sub: "u1" }; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + "base64url", + ); + const fakeJwt = `h.${encodedPayload}.s`; + + mockOptima.user.refreshSession.mockResolvedValueOnce({ + accessToken: "new-access", + refreshToken: "new-refresh", + }); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return fakeJwt; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok"); + expect(event.cookies.set).toHaveBeenCalledWith( + "accessToken", + "new-access", + { path: "/" }, + ); + expect(resolve).toHaveBeenCalledWith(event); + }); + + // ── Expired token, no refresh token — force logout ──────────────── + + it("forces logout when token expired and no refresh token", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + const payload = { exp: Math.floor(Date.now() / 1000) - 100, sub: "u1" }; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + "base64url", + ); + const fakeJwt = `h.${encodedPayload}.s`; + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return fakeJwt; + if (name === "refreshToken") return null; + return null; + }); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(mockOptima.user.logout).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + }); + + // ── Invalid signature error — force logout ──────────────────────── + + it("forces logout on invalid signature error during token decode", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockIsInvalidSignatureError.mockReturnValue(true); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + // Return a malformed JWT that will throw when parsed + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return "bad.!!!.token"; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(mockOptima.user.logout).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + }); + + // ── Malformed token, refresh succeeds ───────────────────────────── + + it("refreshes when access token is malformed and refresh token exists", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockIsInvalidSignatureError.mockReturnValue(false); + mockOptima.user.refreshSession.mockResolvedValueOnce({ + accessToken: "recovered-access", + refreshToken: "recovered-refresh", + }); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return "totally.broken"; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok"); + expect(resolve).toHaveBeenCalled(); + }); + + // ── No access token, has refresh — try refresh ──────────────────── + + it("attempts refresh when only refresh token exists", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockOptima.user.refreshSession.mockResolvedValueOnce({ + accessToken: "new-access", + refreshToken: "new-refresh", + }); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return null; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + await handle({ event, resolve } as any); + + expect(mockOptima.user.refreshSession).toHaveBeenCalledWith("refresh-tok"); + expect(resolve).toHaveBeenCalled(); + }); + + // ── No access token, refresh fails — force logout ───────────────── + + it("forces logout when only refresh token exists but refresh fails", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + mockIsInvalidSignatureError.mockReturnValue(false); + mockOptima.user.refreshSession.mockRejectedValueOnce( + new Error("Refresh failed"), + ); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return null; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(mockOptima.user.logout).toHaveBeenCalled(); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + }); + + // ── Refresh fails with invalid signature ────────────────────────── + + it("logs warning on invalid signature during refresh fallback", async () => { + mockApi.get.mockResolvedValueOnce({ status: 418 }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // First call: malformed token parse error (not invalid signature) + // Second call: refresh also fails with invalid signature + mockIsInvalidSignatureError + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + mockOptima.user.refreshSession.mockRejectedValueOnce( + new Error("invalid signature"), + ); + + const event = createMockEvent({ + url: new URL("http://localhost/dashboard"), + }); + event.cookies.get = vi.fn((name: string) => { + if (name === "accessToken") return "bad.!!!.token"; + if (name === "refreshToken") return "refresh-tok"; + return null; + }); + const resolve = createResolve(); + + try { + await handle({ event, resolve } as any); + } catch { + // redirect throws + } + + expect(warnSpy).toHaveBeenCalledWith( + "Invalid refresh token signature — forcing logout.", + ); + expect(mockOptima.user.logout).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/src/lib/index.spec.ts b/src/lib/index.spec.ts new file mode 100644 index 0000000..fbe83f1 --- /dev/null +++ b/src/lib/index.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { optima } from "./index"; + +describe("optima aggregate export", () => { + it("exports all expected API modules", () => { + expect(optima).toBeDefined(); + expect(optima.auth).toBeDefined(); + expect(optima.company).toBeDefined(); + expect(optima.credential).toBeDefined(); + expect(optima.credentialType).toBeDefined(); + expect(optima.role).toBeDefined(); + expect(optima.permission).toBeDefined(); + expect(optima.user).toBeDefined(); + expect(optima.users).toBeDefined(); + expect(optima.unifi).toBeDefined(); + expect(optima.procurement).toBeDefined(); + expect(optima.sales).toBeDefined(); + expect(optima.cw).toBeDefined(); + }); + + it("auth module has fetchAuthRedirectUri", () => { + expect(typeof optima.auth.fetchAuthRedirectUri).toBe("function"); + }); + + it("user module has expected methods", () => { + expect(typeof optima.user.isLoggedIn).toBe("function"); + expect(typeof optima.user.refreshSession).toBe("function"); + expect(typeof optima.user.fetchInfo).toBe("function"); + expect(typeof optima.user.logout).toBe("function"); + expect(typeof optima.user.checkPermissions).toBe("function"); + expect(typeof optima.user.awaitAuthCallback).toBe("function"); + }); + + it("cw module has fetchMembers", () => { + expect(typeof optima.cw.fetchMembers).toBe("function"); + }); +}); diff --git a/src/lib/optima-api/modules/companies.ts b/src/lib/optima-api/modules/companies.ts index 8d6f14a..5e69c65 100644 --- a/src/lib/optima-api/modules/companies.ts +++ b/src/lib/optima-api/modules/companies.ts @@ -60,4 +60,12 @@ export const company = { ); return configurations.data; }, + async fetchSites(accessToken: string, id: string) { + const response = await api.get(`/v1/company/companies/${id}/sites`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; + }, }; diff --git a/src/lib/optima-api/modules/cw.spec.ts b/src/lib/optima-api/modules/cw.spec.ts new file mode 100644 index 0000000..ef21f06 --- /dev/null +++ b/src/lib/optima-api/modules/cw.spec.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockApi } = vi.hoisted(() => ({ + mockApi: { + get: vi.fn(), + }, +})); + +vi.mock("../axios", () => ({ + default: mockApi, + api: mockApi, +})); + +import { cw, type CWMember } from "./cw"; + +describe("cw module", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetchMembers calls /v1/cw/members with auth header", async () => { + const mockMembers: CWMember[] = [ + { + id: 1, + identifier: "jdoe", + firstName: "John", + lastName: "Doe", + name: "John Doe", + officeEmail: "jdoe@example.com", + inactive: false, + }, + ]; + mockApi.get.mockResolvedValueOnce({ data: { data: mockMembers } }); + + const result = await cw.fetchMembers("test-token"); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", { + params: {}, + headers: { Authorization: "Bearer test-token" }, + }); + expect(result).toEqual(mockMembers); + }); + + it("fetchMembers passes active=false param when specified", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + + await cw.fetchMembers("test-token", { active: false }); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", { + params: { active: "false" }, + headers: { Authorization: "Bearer test-token" }, + }); + }); + + it("fetchMembers does not set active param when active is true", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + + await cw.fetchMembers("test-token", { active: true }); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/cw/members", { + params: {}, + headers: { Authorization: "Bearer test-token" }, + }); + }); + + it("fetchMembers propagates API errors", async () => { + mockApi.get.mockRejectedValueOnce(new Error("Network error")); + + await expect(cw.fetchMembers("test-token")).rejects.toThrow( + "Network error", + ); + }); + + it("fetchMembers returns empty array when API returns empty", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + + const result = await cw.fetchMembers("test-token"); + + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/optima-api/modules/permissions.ts b/src/lib/optima-api/modules/permissions.ts index 8300321..1529dce 100644 --- a/src/lib/optima-api/modules/permissions.ts +++ b/src/lib/optima-api/modules/permissions.ts @@ -5,12 +5,19 @@ export interface PermissionNode { description: string; usedIn: string[]; dependencies?: string[]; + /** + * When present, lists field-level permission nodes gated by this + * permission via `processObjectValuePerms`. Each entry is a full + * permission node string (e.g. `obj.company.id`). + */ + fieldLevelPermissions?: string[]; } export interface PermissionCategory { name: string; description: string; permissions: PermissionNode[]; + subCategories?: Record; } export interface PermissionsCategorized { diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index b5160b2..3cee9a6 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -92,8 +92,187 @@ export interface SalesOpportunity { cwLastUpdated?: string | null; createdAt?: string; updatedAt?: string; + activities?: OpportunityActivity[]; } +// ── Workflow Types ────────────────────────────────────────────────── + +export type WorkflowAction = + | "acceptNew" + | "requestReview" + | "reviewDecision" + | "sendQuote" + | "confirmQuote" + | "finalize" + | "resurrect" + | "beginRevision" + | "resendQuote" + | "cancel" + | "reopen"; + +export type ReviewDecision = "approve" | "reject" | "send" | "cancel"; + +export interface WorkflowActionPayload { + note?: string; + timeStarted?: string; + timeEnded?: string; + // sendQuote / resendQuote compound flags + quoteConfirmed?: boolean; + won?: boolean; + lost?: boolean; + needsRevision?: boolean; + // reviewDecision + decision?: ReviewDecision; + // finalize + outcome?: "won" | "lost"; + finalize?: boolean; +} + +export interface WorkflowAvailableAction { + action: WorkflowAction; + label: string; + targetStatuses: { key: string; id: number }[]; + requiresNote: boolean; + requiresPermission: string | null; + permitted: boolean; + payloadHints?: Record; +} + +export interface WorkflowStatusResponse { + currentStatusId: number; + currentStatus: string; + stageName: string; + isOptimaStage: boolean; + isTerminal: boolean; + availableActions: WorkflowAvailableAction[]; + coldCheck: ColdCheckResult | null; +} + +export interface ColdCheckResult { + isCold: boolean; + daysSinceActivity?: number; + threshold?: number; + status?: string; +} + +export interface WorkflowResult { + previousStatusId: number; + previousStatus: string; + newStatusId: number | null; + newStatus: string | null; + activitiesCreated: OpportunityActivity[]; + coldCheck: ColdCheckResult | null; +} + +export interface OpportunityActivity { + cwActivityId?: number; + name?: string; + notes?: string; + type?: { id?: number; name?: string }; + status?: { id?: number; name?: string }; + company?: { id?: number; identifier?: string; name?: string }; + contact?: { id?: number; name?: string }; + phoneNumber?: string; + email?: string; + opportunity?: { id?: number; name?: string }; + ticket?: unknown; + agreement?: unknown; + campaign?: unknown; + assignTo?: { id?: number; identifier?: string; name?: string }; + scheduleStatus?: unknown; + reminder?: unknown; + where?: string | null; + dateStart?: string; + dateEnd?: string; + notifyFlag?: boolean; + currency?: unknown; + mobileGuid?: string | null; + customFields?: { id?: number; caption?: string; value?: string }[]; + cwLastUpdated?: string; + cwDateEntered?: string; + cwEnteredBy?: string; + cwUpdatedBy?: string; + closed?: boolean; + closedAt?: string; +} + +export interface WorkflowHistoryEntry { + activity: OpportunityActivity; + optimaType: string; + quoteId?: string | null; + closed?: boolean; + closedAt?: string | null; +} + +export interface WorkflowHistoryResponse { + opportunityId: string; + cwOpportunityId: number; + totalActivities: number; + activities: WorkflowHistoryEntry[]; +} + +// ── Workflow Status IDs ───────────────────────────────────────────── + +export const WORKFLOW_STATUS_IDS = { + PendingNew: 37, + New: 24, + InternalReview: 56, + QuoteSent: 43, + ConfirmedQuote: 57, + Active: 58, + PendingSent: 60, + PendingRevision: 61, + PendingWon: 49, + Won: 29, + PendingLost: 50, + Lost: 53, + Canceled: 59, +} as const; + +export type WorkflowStatusKey = keyof typeof WORKFLOW_STATUS_IDS; + +/** Reverse lookup: CW status ID → workflow status key */ +export const STATUS_ID_TO_KEY: Record = + Object.fromEntries( + Object.entries(WORKFLOW_STATUS_IDS).map(([k, v]) => [ + v, + k as WorkflowStatusKey, + ]), + ) as Record; + +/** Human-readable labels for each workflow status */ +export const WORKFLOW_STATUS_LABELS: Record = { + PendingNew: "Pending New", + New: "New", + InternalReview: "Internal Review", + QuoteSent: "Quote Sent", + ConfirmedQuote: "Confirmed Quote", + Active: "Active", + PendingSent: "Pending Sent", + PendingRevision: "Pending Revision", + PendingWon: "Pending Won", + Won: "Won", + PendingLost: "Pending Lost", + Lost: "Lost", + Canceled: "Canceled", +}; + +/** Terminal statuses — no workflow actions allowed */ +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + "Won", + "Lost", +]); + +/** Statuses that can be re-opened */ +export const REOPENABLE_STATUSES: ReadonlySet = new Set([ + "Canceled", +]); + +/** Statuses where the quote has been confirmed (finalize is allowed) */ +export const QUOTE_CONFIRMED_STATUSES: ReadonlySet = new Set( + ["ConfirmedQuote", "Active", "PendingWon", "PendingLost"], +); + export interface OpportunityType { id: number; name: string; @@ -741,4 +920,98 @@ export const sales = { successful?: boolean; }; }, + + async deleteProduct( + accessToken: string, + identifier: string, + productId: number, + ) { + const response = await api.delete( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/${productId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + + async deleteOpportunity(accessToken: string, identifier: string) { + const response = await api.delete( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + + // ── Workflow Methods ────────────────────────────────────────────── + + async fetchWorkflowStatus(accessToken: string, identifier: string) { + const response = await api.get( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/workflow`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data as { + status?: number; + message?: string; + data: WorkflowStatusResponse; + successful?: boolean; + }; + }, + + async dispatchWorkflowAction( + accessToken: string, + identifier: string, + action: WorkflowAction, + payload: WorkflowActionPayload, + ) { + const response = await api.post( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/workflow`, + { action, payload }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data as { + status?: number; + message?: string; + data: WorkflowResult; + successful?: boolean; + }; + }, + + async fetchWorkflowHistory( + accessToken: string, + identifier: string, + type?: string, + ) { + const params: Record = {}; + if (type) params.type = type; + const response = await api.get( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/workflow/history`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data as { + status?: number; + message?: string; + data: WorkflowHistoryResponse; + successful?: boolean; + }; + }, }; diff --git a/src/routes/(auth)/login/+page.server.spec.ts b/src/routes/(auth)/login/+page.server.spec.ts new file mode 100644 index 0000000..fc0e5b1 --- /dev/null +++ b/src/routes/(auth)/login/+page.server.spec.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockRedirect } = vi.hoisted(() => ({ + mockOptima: { + user: { + awaitAuthCallback: vi.fn(), + }, + }, + mockRedirect: vi.fn((status: number, location: string) => { + throw { status, location }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ + redirect: mockRedirect, + // Actions type is needed + Actions: {}, +})); + +import { actions } from "./+page.server"; + +describe("(auth)/login +page.server.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("login action", () => { + it("sets cookies and redirects on successful auth callback", async () => { + mockOptima.user.awaitAuthCallback.mockResolvedValueOnce({ + accessToken: "access-tok", + refreshToken: "refresh-tok", + }); + + const setCookie = vi.fn(); + const formData = { + get: vi.fn((key: string) => { + if (key === "callbackKey") return "cb-123"; + return null; + }), + }; + + await expect( + actions.login({ + request: { + formData: vi.fn().mockResolvedValue(formData), + }, + cookies: { + set: setCookie, + }, + } as any), + ).rejects.toEqual( + expect.objectContaining({ status: 303, location: "/" }), + ); + + expect(mockOptima.user.awaitAuthCallback).toHaveBeenCalledWith("cb-123"); + expect(setCookie).toHaveBeenCalledWith("accessToken", "access-tok", { + httpOnly: true, + path: "/", + }); + expect(setCookie).toHaveBeenCalledWith("refreshToken", "refresh-tok", { + httpOnly: true, + path: "/", + }); + }); + + it("propagates errors from awaitAuthCallback", async () => { + mockOptima.user.awaitAuthCallback.mockRejectedValueOnce( + new Error("Timed out"), + ); + + const formData = { + get: vi.fn(() => "cb-123"), + }; + + await expect( + actions.login({ + request: { + formData: vi.fn().mockResolvedValue(formData), + }, + cookies: { set: vi.fn() }, + } as any), + ).rejects.toThrow("Timed out"); + }); + }); +}); diff --git a/src/routes/+layout.server.spec.ts b/src/routes/+layout.server.spec.ts new file mode 100644 index 0000000..a25f3e6 --- /dev/null +++ b/src/routes/+layout.server.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima } = vi.hoisted(() => ({ + mockOptima: { + user: { + fetchInfo: vi.fn(), + checkPermissions: vi.fn(), + }, + }, +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); + +import { load } from "./+layout.server"; + +describe("root +layout.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns canViewAdmin false when no access token", async () => { + const result = await load({ + locals: {}, + } as any); + + expect(result).toEqual({ canViewAdmin: false }); + }); + + it("returns canViewAdmin false when session accessToken is null", async () => { + const result = await load({ + locals: { session: { accessToken: null } }, + } as any); + + expect(result).toEqual({ canViewAdmin: false }); + }); + + it("returns canViewAdmin true when permission is granted", async () => { + mockOptima.user.fetchInfo.mockResolvedValueOnce({ + data: { id: "u1" }, + }); + mockOptima.user.checkPermissions.mockResolvedValueOnce({ + data: { + results: [ + { permission: "ui.navigation.admin.view", hasPermission: true }, + ], + }, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toEqual({ canViewAdmin: true }); + }); + + it("returns canViewAdmin false when permission is denied", async () => { + mockOptima.user.fetchInfo.mockResolvedValueOnce({ + data: { id: "u1" }, + }); + mockOptima.user.checkPermissions.mockResolvedValueOnce({ + data: { + results: [ + { permission: "ui.navigation.admin.view", hasPermission: false }, + ], + }, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toEqual({ canViewAdmin: false }); + }); + + it("returns canViewAdmin false on permission check error", async () => { + mockOptima.user.fetchInfo.mockRejectedValueOnce(new Error("fail")); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toEqual({ canViewAdmin: false }); + }); +}); diff --git a/src/routes/admin/+layout.server.spec.ts b/src/routes/admin/+layout.server.spec.ts new file mode 100644 index 0000000..0ad35c6 --- /dev/null +++ b/src/routes/admin/+layout.server.spec.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError, mockRedirect } = + vi.hoisted(() => ({ + mockOptima: { + user: { fetchInfo: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + mockRedirect: vi.fn((status: number, location: string) => { + throw { status, location }; + }), + })); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); +vi.mock("@sveltejs/kit", () => ({ + redirect: mockRedirect, +})); + +import { load } from "./+layout.server"; + +describe("admin +layout.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("redirects to /login when no access token", async () => { + await expect( + load({ + locals: {}, + parent: vi.fn().mockResolvedValue({}), + } as any), + ).rejects.toEqual( + expect.objectContaining({ status: 303, location: "/login" }), + ); + }); + + it("redirects to / when canViewAdmin is false", async () => { + await expect( + load({ + locals: { session: { accessToken: "tok" } }, + parent: vi.fn().mockResolvedValue({ canViewAdmin: false }), + } as any), + ).rejects.toEqual(expect.objectContaining({ status: 303, location: "/" })); + }); + + it("returns user and permissions when authorized", async () => { + mockCheckPermissions.mockResolvedValueOnce({ + "admin.users.view": true, + "admin.roles.view": true, + "admin.credential-types.view": true, + "ui.navigation.reports.view": true, + }); + mockOptima.user.fetchInfo.mockResolvedValueOnce({ + data: { id: "u1", name: "Admin" }, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + parent: vi.fn().mockResolvedValue({ canViewAdmin: true }), + } as any); + + expect(result).toMatchObject({ + user: { id: "u1", name: "Admin" }, + permissions: expect.objectContaining({ + "ui.navigation.admin.view": true, + "admin.users.view": true, + }), + }); + }); +}); diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts index 63d5d5d..4e360f3 100644 --- a/src/routes/admin/+layout.server.ts +++ b/src/routes/admin/+layout.server.ts @@ -25,6 +25,7 @@ export const load: LayoutServerLoad = async ({ locals, parent }) => { "admin.users.view", "admin.roles.view", "admin.credential-types.view", + "ui.navigation.reports.view", ]), optima.user.fetchInfo(accessToken), ]); diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 04b4067..c14bd8f 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -38,6 +38,12 @@ exact: false, permission: "admin.credential-types.view", }, + { + label: "Reports", + href: "/admin/reports", + exact: false, + permission: "ui.navigation.reports.view", + }, ] as const; // Only show tabs the user has permission for diff --git a/src/routes/admin/+page.server.spec.ts b/src/routes/admin/+page.server.spec.ts new file mode 100644 index 0000000..f73fb87 --- /dev/null +++ b/src/routes/admin/+page.server.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockHandleApiError } = vi.hoisted(() => ({ + mockOptima: { + company: { count: vi.fn() }, + }, + mockHandleApiError: vi.fn(), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); + +import { load } from "./+page.server"; + +describe("admin +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null companyCount when no token", async () => { + const result = await load({ + locals: {}, + } as any); + + expect(result).toEqual({ companyCount: null }); + }); + + it("returns company count on success", async () => { + mockOptima.company.count.mockResolvedValueOnce(42); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toEqual({ companyCount: 42 }); + }); + + it("calls handleApiError on failure", async () => { + const err = new Error("fail"); + mockOptima.company.count.mockRejectedValueOnce(err); + + await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(mockHandleApiError).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/routes/admin/credential-types/+page.server.spec.ts b/src/routes/admin/credential-types/+page.server.spec.ts new file mode 100644 index 0000000..4c81fde --- /dev/null +++ b/src/routes/admin/credential-types/+page.server.spec.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } = + vi.hoisted(() => ({ + mockOptima: { + credentialType: { + fetchMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + credential: { fetchValueTypes: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + mockFail: vi.fn((status: number, data: any) => ({ + status, + data, + })), + })); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); +vi.mock("@sveltejs/kit", () => ({ + fail: mockFail, +})); + +import { load, actions } from "./+page.server"; + +describe("admin/credential-types +page.server.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("load", () => { + it("returns empty data when no token", async () => { + const result = await load({ locals: {} } as any); + expect(result).toEqual({ + credentialTypes: [], + permissions: {}, + valueTypes: [], + }); + }); + + it("loads credential types with permissions and value types", async () => { + mockOptima.credentialType.fetchMany.mockResolvedValueOnce({ + data: [{ id: "ct1", name: "SSH Key" }], + }); + mockCheckPermissions.mockResolvedValueOnce({ + "admin.credential-types.view": true, + }); + mockOptima.credential.fetchValueTypes.mockResolvedValueOnce({ + data: ["text", "password"], + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toMatchObject({ + credentialTypes: [{ id: "ct1", name: "SSH Key" }], + valueTypes: ["text", "password"], + }); + }); + }); + + describe("actions", () => { + function createFormData(entries: Record) { + return { + get: (key: string) => entries[key] ?? null, + getAll: (key: string) => (entries[key] ? [entries[key]] : []), + }; + } + + describe("createCredentialType", () => { + it("returns 401 when no token", async () => { + await actions.createCredentialType({ + locals: {}, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(401, { + message: "Not authenticated.", + }); + }); + + it("returns 400 when required fields missing", async () => { + await actions.createCredentialType({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi + .fn() + .mockResolvedValue(createFormData({ name: "SSH" })), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Name and permission scope are required.", + }); + }); + + it("creates credential type successfully", async () => { + mockOptima.credentialType.create.mockResolvedValueOnce({}); + + const result = await actions.createCredentialType({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue( + createFormData({ + name: "SSH Key", + permissionScope: "ssh", + fields: '[{"name":"key","type":"text"}]', + }), + ), + }, + } as any); + + expect(mockOptima.credentialType.create).toHaveBeenCalledWith("tok", { + name: "SSH Key", + permissionScope: "ssh", + icon: undefined, + fields: [{ name: "key", type: "text" }], + }); + expect(result).toEqual({}); + }); + + it("returns 400 for invalid fields JSON", async () => { + await actions.createCredentialType({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue( + createFormData({ + name: "SSH Key", + permissionScope: "ssh", + fields: "bad json", + }), + ), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Invalid fields data.", + }); + }); + }); + + describe("deleteCredentialType", () => { + it("returns 400 when id missing", async () => { + await actions.deleteCredentialType({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Credential type ID is required.", + }); + }); + + it("deletes credential type", async () => { + mockOptima.credentialType.delete.mockResolvedValueOnce({}); + + const result = await actions.deleteCredentialType({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({ id: "ct1" })), + }, + } as any); + + expect(mockOptima.credentialType.delete).toHaveBeenCalledWith( + "tok", + "ct1", + ); + expect(result).toEqual({}); + }); + }); + }); +}); diff --git a/src/routes/admin/reports/+page.server.spec.ts b/src/routes/admin/reports/+page.server.spec.ts new file mode 100644 index 0000000..2453a79 --- /dev/null +++ b/src/routes/admin/reports/+page.server.spec.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockCheckPermissions, mockRedirect } = vi.hoisted(() => ({ + mockCheckPermissions: vi.fn(), + mockRedirect: vi.fn((status: number, location: string) => { + throw { status, location }; + }), +})); + +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("@sveltejs/kit", () => ({ + redirect: mockRedirect, +})); + +import { load } from "./+page.server"; + +describe("admin/reports +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("redirects to /login when no access token", async () => { + await expect( + load({ + locals: {}, + parent: vi.fn().mockResolvedValue({}), + } as any), + ).rejects.toEqual( + expect.objectContaining({ status: 303, location: "/login" }), + ); + }); + + it("redirects to /admin when reports permission is false", async () => { + await expect( + load({ + locals: { session: { accessToken: "tok" } }, + parent: vi.fn().mockResolvedValue({ + permissions: { "ui.navigation.reports.view": false }, + }), + } as any), + ).rejects.toEqual( + expect.objectContaining({ status: 303, location: "/admin" }), + ); + }); + + it("returns accessToken when permitted", async () => { + const result = await load({ + locals: { session: { accessToken: "tok" } }, + parent: vi.fn().mockResolvedValue({ + permissions: { "ui.navigation.reports.view": true }, + }), + } as any); + + expect(result).toEqual({ accessToken: "tok" }); + }); +}); diff --git a/src/routes/admin/reports/+page.server.ts b/src/routes/admin/reports/+page.server.ts new file mode 100644 index 0000000..946103d --- /dev/null +++ b/src/routes/admin/reports/+page.server.ts @@ -0,0 +1,22 @@ +import { checkPermissions } from "$lib/permissions"; +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals, parent }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) { + throw redirect(303, "/login"); + } + + const parentData = await parent(); + const permissions = parentData?.permissions ?? {}; + + // Gate access to reports + if (permissions["ui.navigation.reports.view"] === false) { + throw redirect(303, "/admin"); + } + + return { + accessToken, + }; +}; diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte new file mode 100644 index 0000000..da19950 --- /dev/null +++ b/src/routes/admin/reports/+page.svelte @@ -0,0 +1,244 @@ + + +
+
+ + + +

Workflow Reports

+
+ + +
+ {#each reportTabs as tab} + + {/each} +
+ + +
+ {#each reportTabs as tab} + {#if activeReport === tab.id} +
+
+ + + +

{tab.label}

+

{tab.description}

+

+ Report endpoints are being built. This view will populate + automatically when the API is ready. +

+
+
+ {/if} + {/each} +
+
+ + diff --git a/src/routes/admin/roles/+page.server.spec.ts b/src/routes/admin/roles/+page.server.spec.ts new file mode 100644 index 0000000..ddfdab8 --- /dev/null +++ b/src/routes/admin/roles/+page.server.spec.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } = + vi.hoisted(() => ({ + mockOptima: { + role: { + fetchMany: vi.fn(), + fetchUsers: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + permission: { fetchCategorized: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + mockFail: vi.fn((status: number, data: any) => ({ + status, + data, + })), + })); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); +vi.mock("@sveltejs/kit", () => ({ + fail: mockFail, +})); + +import { load, actions } from "./+page.server"; + +describe("admin/roles +page.server.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("load", () => { + it("returns empty data when no token", async () => { + const result = await load({ locals: {} } as any); + expect(result).toEqual({ + roles: [], + permissions: {}, + permissionNodes: {}, + }); + }); + + it("loads roles with users and permissions", async () => { + mockOptima.role.fetchMany.mockResolvedValueOnce({ + data: [{ id: "r1", title: "Admin" }], + }); + mockCheckPermissions.mockResolvedValueOnce({ + "admin.roles.view": true, + }); + mockOptima.permission.fetchCategorized.mockResolvedValueOnce({ + data: { category1: ["perm.a"] }, + }); + mockOptima.role.fetchUsers.mockResolvedValueOnce({ + data: [{ id: "u1" }], + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toMatchObject({ + roles: [ + expect.objectContaining({ + id: "r1", + users: [{ id: "u1" }], + }), + ], + permissionNodes: { category1: ["perm.a"] }, + }); + }); + }); + + describe("actions", () => { + function createFormData(entries: Record) { + const fd = new Map(); + return { + get: (key: string) => { + const val = entries[key]; + return Array.isArray(val) ? val[0] : (val ?? null); + }, + getAll: (key: string) => { + const val = entries[key]; + return Array.isArray(val) ? val : val ? [val] : []; + }, + }; + } + + describe("createRole", () => { + it("returns 401 when no token", async () => { + const result = await actions.createRole({ + locals: {}, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(401, { + message: "Not authenticated.", + }); + }); + + it("returns 400 when title is missing", async () => { + await actions.createRole({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi + .fn() + .mockResolvedValue(createFormData({ moniker: "admin" })), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Title and moniker are required.", + }); + }); + + it("creates role successfully", async () => { + mockOptima.role.create.mockResolvedValueOnce({}); + + const result = await actions.createRole({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue( + createFormData({ + title: "Admin", + moniker: "admin", + permissions: ["perm.a", "perm.b"], + }), + ), + }, + } as any); + + expect(mockOptima.role.create).toHaveBeenCalledWith("tok", { + title: "Admin", + moniker: "admin", + permissions: ["perm.a", "perm.b"], + }); + expect(result).toEqual({}); + }); + }); + + describe("deleteRole", () => { + it("returns 400 when id is missing", async () => { + await actions.deleteRole({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Role ID is required.", + }); + }); + + it("deletes role successfully", async () => { + mockOptima.role.delete.mockResolvedValueOnce({}); + + const result = await actions.deleteRole({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({ id: "r1" })), + }, + } as any); + + expect(mockOptima.role.delete).toHaveBeenCalledWith("tok", "r1"); + expect(result).toEqual({}); + }); + }); + }); +}); diff --git a/src/routes/admin/users/+page.server.spec.ts b/src/routes/admin/users/+page.server.spec.ts new file mode 100644 index 0000000..134e858 --- /dev/null +++ b/src/routes/admin/users/+page.server.spec.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } = + vi.hoisted(() => ({ + mockOptima: { + users: { + fetchAll: vi.fn(), + fetchRoles: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + role: { fetchMany: vi.fn() }, + permission: { fetchCategorized: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + mockFail: vi.fn((status: number, data: any) => ({ + status, + data, + })), + })); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); +vi.mock("@sveltejs/kit", () => ({ + fail: mockFail, +})); + +import { load, actions } from "./+page.server"; + +describe("admin/users +page.server.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("load", () => { + it("returns empty data when no token", async () => { + const result = await load({ locals: {} } as any); + expect(result).toEqual({ + users: [], + roles: [], + permissions: {}, + }); + }); + + it("loads users with roles", async () => { + mockOptima.users.fetchAll.mockResolvedValueOnce({ + data: [{ id: "u1", name: "John" }], + }); + mockOptima.role.fetchMany.mockResolvedValueOnce({ + data: [{ id: "r1" }], + }); + mockCheckPermissions.mockResolvedValueOnce({ + "admin.users.view": true, + }); + mockOptima.permission.fetchCategorized.mockResolvedValueOnce({ + data: {}, + }); + mockOptima.users.fetchRoles.mockResolvedValueOnce({ + data: [{ id: "r1" }], + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + } as any); + + expect(result).toMatchObject({ + users: [ + expect.objectContaining({ + id: "u1", + roleDetails: [{ id: "r1" }], + }), + ], + roles: [{ id: "r1" }], + }); + }); + }); + + describe("actions", () => { + function createFormData(entries: Record) { + return { + get: (key: string) => entries[key] ?? null, + getAll: (key: string) => (entries[key] ? [entries[key]] : []), + }; + } + + describe("updateUser", () => { + it("returns 401 when no token", async () => { + await actions.updateUser({ + locals: {}, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(401, { + message: "Not authenticated.", + }); + }); + + it("returns 400 when required fields are missing", async () => { + await actions.updateUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({ id: "u1" })), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "User ID and name are required.", + }); + }); + + it("updates user successfully", async () => { + mockOptima.users.update.mockResolvedValueOnce({}); + + const result = await actions.updateUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi + .fn() + .mockResolvedValue(createFormData({ id: "u1", name: "Updated" })), + }, + } as any); + + expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", { + name: "Updated", + image: undefined, + }); + expect(result).toEqual({}); + }); + + it("parses roles JSON when provided", async () => { + mockOptima.users.update.mockResolvedValueOnce({}); + + await actions.updateUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue( + createFormData({ + id: "u1", + name: "Updated", + roles: '["r1","r2"]', + }), + ), + }, + } as any); + + expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", { + name: "Updated", + image: undefined, + roles: ["r1", "r2"], + }); + }); + + it("returns 400 for invalid roles JSON", async () => { + await actions.updateUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue( + createFormData({ + id: "u1", + name: "Updated", + roles: "bad json", + }), + ), + }, + } as any); + + expect(mockFail).toHaveBeenCalledWith(400, { + message: "Invalid roles data.", + }); + }); + }); + + describe("deleteUser", () => { + it("returns 400 when id is missing", async () => { + await actions.deleteUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({})), + }, + } as any); + expect(mockFail).toHaveBeenCalledWith(400, { + message: "User ID is required.", + }); + }); + + it("deletes user successfully", async () => { + mockOptima.users.delete.mockResolvedValueOnce({}); + + const result = await actions.deleteUser({ + locals: { session: { accessToken: "tok" } }, + request: { + formData: vi.fn().mockResolvedValue(createFormData({ id: "u1" })), + }, + } as any); + + expect(mockOptima.users.delete).toHaveBeenCalledWith("tok", "u1"); + expect(result).toEqual({}); + }); + }); + }); +}); diff --git a/src/routes/api/auth/check/+server.spec.ts b/src/routes/api/auth/check/+server.spec.ts new file mode 100644 index 0000000..a45639e --- /dev/null +++ b/src/routes/api/auth/check/+server.spec.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockJson, mockError } = vi.hoisted(() => ({ + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("@sveltejs/kit", () => ({ + json: mockJson, + error: mockError, +})); + +import { GET } from "./+server"; + +describe("GET /api/auth/check", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when no access token", async () => { + const event = { + locals: { session: { accessToken: null } }, + }; + + const response = GET(event as any); + + expect(mockJson).toHaveBeenCalledWith( + { authenticated: false }, + { status: 401 }, + ); + }); + + it("returns 401 when session is undefined", async () => { + const event = { + locals: {}, + }; + + const response = GET(event as any); + + expect(mockJson).toHaveBeenCalledWith( + { authenticated: false }, + { status: 401 }, + ); + }); + + it("returns authenticated true when access token exists", async () => { + const event = { + locals: { session: { accessToken: "valid-token" } }, + }; + + const response = GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ authenticated: true }); + }); +}); diff --git a/src/routes/api/companies/[id]/details/+server.spec.ts b/src/routes/api/companies/[id]/details/+server.spec.ts new file mode 100644 index 0000000..72fe018 --- /dev/null +++ b/src/routes/api/companies/[id]/details/+server.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + company: { fetch: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET } from "./+server"; + +describe("GET /api/companies/[id]/details", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns 401 when no token", async () => { + const event = { locals: {}, params: { id: "123" } }; + GET(event as any); + expect(mockJson).toHaveBeenCalledWith({ data: null }, { status: 401 }); + }); + + it("fetches company with contacts and address", async () => { + mockOptima.company.fetch.mockResolvedValueOnce({ + data: { id: "123", name: "Acme" }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "123" }, + }; + + await GET(event as any); + + expect(mockOptima.company.fetch).toHaveBeenCalledWith("tok", "123", { + includeAllContacts: true, + includeAddress: true, + }); + expect(mockJson).toHaveBeenCalledWith({ + data: { id: "123", name: "Acme" }, + }); + }); + + it("returns 500 on API failure", async () => { + mockOptima.company.fetch.mockRejectedValueOnce(new Error("fail")); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "123" }, + }; + + await GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: null }, { status: 500 }); + }); +}); diff --git a/src/routes/api/companies/[id]/sites/+server.spec.ts b/src/routes/api/companies/[id]/sites/+server.spec.ts new file mode 100644 index 0000000..dc5cd3c --- /dev/null +++ b/src/routes/api/companies/[id]/sites/+server.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + company: { fetchSites: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET } from "./+server"; + +describe("GET /api/companies/[id]/sites", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns 401 when no token", async () => { + const event = { locals: {}, params: { id: "123" } }; + GET(event as any); + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 401 }); + }); + + it("fetches sites successfully", async () => { + mockOptima.company.fetchSites.mockResolvedValueOnce({ + data: [{ id: "s1", name: "Main" }], + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "123" }, + }; + + await GET(event as any); + + expect(mockOptima.company.fetchSites).toHaveBeenCalledWith("tok", "123"); + expect(mockJson).toHaveBeenCalledWith({ + data: [{ id: "s1", name: "Main" }], + }); + }); + + it("returns 500 on failure", async () => { + mockOptima.company.fetchSites.mockRejectedValueOnce(new Error("fail")); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "123" }, + }; + + await GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 500 }); + }); +}); diff --git a/src/routes/api/companies/[id]/sites/+server.ts b/src/routes/api/companies/[id]/sites/+server.ts new file mode 100644 index 0000000..eceae96 --- /dev/null +++ b/src/routes/api/companies/[id]/sites/+server.ts @@ -0,0 +1,19 @@ +import { optima } from "$lib"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** GET /api/companies/[id]/sites — fetch CW sites for a company */ +export const GET: RequestHandler = async ({ params, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) { + return json({ data: [] }, { status: 401 }); + } + + try { + const result = await optima.company.fetchSites(accessToken, params.id); + return json({ data: result?.data ?? [] }); + } catch (err) { + console.error("[api/companies/sites] Failed:", err); + return json({ data: [] }, { status: 500 }); + } +}; diff --git a/src/routes/api/companies/search/+server.spec.ts b/src/routes/api/companies/search/+server.spec.ts new file mode 100644 index 0000000..cda03bc --- /dev/null +++ b/src/routes/api/companies/search/+server.spec.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + company: { + fetchMany: vi.fn(), + }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ + json: mockJson, + error: mockError, +})); + +import { GET } from "./+server"; + +describe("GET /api/companies/search", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns 401 with empty data when no token", async () => { + const event = { + locals: {}, + url: new URL("http://localhost/api/companies/search"), + }; + + GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 401 }); + }); + + it("fetches companies with search params", async () => { + mockOptima.company.fetchMany.mockResolvedValueOnce({ + data: [{ id: "1", name: "Acme" }], + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL( + "http://localhost/api/companies/search?search=acme&page=2&rpp=10", + ), + }; + + await GET(event as any); + + expect(mockOptima.company.fetchMany).toHaveBeenCalledWith( + "tok", + 2, + "acme", + 10, + ); + expect(mockJson).toHaveBeenCalledWith({ + data: [{ id: "1", name: "Acme" }], + }); + }); + + it("uses default params when not specified", async () => { + mockOptima.company.fetchMany.mockResolvedValueOnce({ data: [] }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/api/companies/search"), + }; + + await GET(event as any); + + expect(mockOptima.company.fetchMany).toHaveBeenCalledWith("tok", 1, "", 15); + }); + + it("returns 500 on fetch failure", async () => { + mockOptima.company.fetchMany.mockRejectedValueOnce(new Error("fail")); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/api/companies/search"), + }; + + await GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 500 }); + }); +}); diff --git a/src/routes/api/cw/members/+server.spec.ts b/src/routes/api/cw/members/+server.spec.ts new file mode 100644 index 0000000..c434a66 --- /dev/null +++ b/src/routes/api/cw/members/+server.spec.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + cw: { + fetchMembers: vi.fn(), + }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ + json: mockJson, + error: mockError, +})); + +import { GET } from "./+server"; + +describe("GET /api/cw/members", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns 401 with empty data when no access token", async () => { + const event = { locals: {} }; + + GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 401 }); + }); + + it("returns members on success", async () => { + const members = [{ id: 1, name: "John" }]; + mockOptima.cw.fetchMembers.mockResolvedValueOnce(members); + + const event = { locals: { session: { accessToken: "tok" } } }; + + await GET(event as any); + + expect(mockOptima.cw.fetchMembers).toHaveBeenCalledWith("tok"); + expect(mockJson).toHaveBeenCalledWith({ data: members }); + }); + + it("returns 500 with empty data on fetch failure", async () => { + mockOptima.cw.fetchMembers.mockRejectedValueOnce(new Error("fail")); + + const event = { locals: { session: { accessToken: "tok" } } }; + + await GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }, { status: 500 }); + }); +}); diff --git a/src/routes/api/sales/opportunities/+server.spec.ts b/src/routes/api/sales/opportunities/+server.spec.ts new file mode 100644 index 0000000..faa9f15 --- /dev/null +++ b/src/routes/api/sales/opportunities/+server.spec.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { createOpportunity: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { POST } from "./+server"; + +describe("POST /api/sales/opportunities", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + request: { + json: vi + .fn() + .mockResolvedValue({ name: "Deal", expectedCloseDate: "2026-12-01" }), + }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 401 }), + ); + }); + + it("throws 400 when name is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi + .fn() + .mockResolvedValue({ name: "", expectedCloseDate: "2026-12-01" }), + }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("throws 400 when expectedCloseDate is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ name: "Deal" }), + }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("creates opportunity successfully", async () => { + const created = { id: "opp-1", name: "Big Deal" }; + mockOptima.sales.createOpportunity.mockResolvedValueOnce(created); + + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ + name: "Big Deal", + expectedCloseDate: "2026-12-01", + notes: "Some notes", + }), + }, + }; + + await POST(event as any); + + expect(mockOptima.sales.createOpportunity).toHaveBeenCalledWith("tok", { + name: "Big Deal", + expectedCloseDate: "2026-12-01", + notes: "Some notes", + type: undefined, + stage: undefined, + status: undefined, + priority: undefined, + rating: undefined, + primarySalesRep: undefined, + secondarySalesRep: undefined, + company: undefined, + contact: undefined, + source: undefined, + customerPO: undefined, + }); + expect(mockJson).toHaveBeenCalledWith(created, { status: 201 }); + }); + + it("returns error status from API failure", async () => { + mockOptima.sales.createOpportunity.mockRejectedValueOnce({ + status: 422, + response: { data: { message: "Validation error" } }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ + name: "Deal", + expectedCloseDate: "2026-12-01", + }), + }, + }; + + await expect(POST(event as any)).rejects.toBeDefined(); + }); +}); diff --git a/src/routes/companies/+page.server.spec.ts b/src/routes/companies/+page.server.spec.ts new file mode 100644 index 0000000..2661d3b --- /dev/null +++ b/src/routes/companies/+page.server.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted( + () => ({ + mockOptima: { + company: { fetchMany: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + }), +); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); + +import { load } from "./+page.server"; + +describe("companies +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty data when no access token", async () => { + const result = await load({ + locals: {}, + url: new URL("http://localhost/companies"), + } as any); + + expect(result).toMatchObject({ + companies: [], + totalPages: 1, + currentPage: 1, + totalRecords: 0, + search: "", + }); + }); + + it("fetches companies with pagination and search", async () => { + mockOptima.company.fetchMany.mockResolvedValueOnce({ + data: [{ id: "c1" }], + meta: { + pagination: { totalPages: 3, currentPage: 2, totalRecords: 45 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({ "companies.view": true }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/companies?page=2&search=acme"), + } as any); + + expect(mockOptima.company.fetchMany).toHaveBeenCalledWith("tok", 2, "acme"); + expect(result).toMatchObject({ + companies: [{ id: "c1" }], + totalPages: 3, + currentPage: 2, + totalRecords: 45, + search: "acme", + }); + }); + + it("clamps page to minimum of 1", async () => { + mockOptima.company.fetchMany.mockResolvedValueOnce({ + data: [], + meta: { + pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({}); + + await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/companies?page=-5"), + } as any); + + expect(mockOptima.company.fetchMany).toHaveBeenCalledWith("tok", 1, ""); + }); + + it("handles API error gracefully when fetchMany fails", async () => { + mockOptima.company.fetchMany.mockRejectedValueOnce(new Error("API down")); + mockCheckPermissions.mockResolvedValueOnce({}); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/companies"), + } as any); + + // fetchMany is in a .catch() so it returns defaults + expect(result).toMatchObject({ + companies: [], + totalPages: 1, + }); + }); +}); diff --git a/src/routes/companies/[id]/+page.server.spec.ts b/src/routes/companies/[id]/+page.server.spec.ts new file mode 100644 index 0000000..72688fc --- /dev/null +++ b/src/routes/companies/[id]/+page.server.spec.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted( + () => ({ + mockOptima: { + company: { fetch: vi.fn(), fetchConfigurations: vi.fn() }, + credential: { fetchByCompany: vi.fn() }, + credentialType: { fetchMany: vi.fn() }, + unifi: { fetchCompanySites: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + }), +); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); + +import { load } from "./+page.server"; + +describe("companies/[id] +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty data when no access token", async () => { + const result = await load({ + locals: {}, + params: { id: "c1" }, + } as any); + + expect(result).toMatchObject({ + company: null, + configurations: [], + credentials: [], + credentialTypes: [], + unifiSites: [], + accessToken: null, + }); + }); + + it("loads company with all related data", async () => { + mockCheckPermissions.mockResolvedValueOnce({ + "company.fetch.address": true, + "company.fetch.contacts": true, + "credential.secure_values.read": true, + "unifi.site.wifi": true, + "unifi.site.wifi.read.name": true, + "unifi.site.wifi.update": false, + }); + mockOptima.company.fetchConfigurations.mockResolvedValueOnce({ + data: [{ id: "cfg-1" }], + }); + mockOptima.credential.fetchByCompany.mockResolvedValueOnce({ + data: [{ id: "cred-1" }], + }); + mockOptima.credentialType.fetchMany.mockResolvedValueOnce({ + data: [{ id: "ct-1" }], + }); + mockOptima.unifi.fetchCompanySites.mockResolvedValueOnce({ + data: [{ id: "site-1" }], + }); + mockOptima.company.fetch.mockResolvedValueOnce({ + data: { id: "c1", name: "Acme" }, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + params: { id: "c1" }, + } as any); + + expect(result).toMatchObject({ + company: { id: "c1", name: "Acme" }, + configurations: [{ id: "cfg-1" }], + credentials: [{ id: "cred-1" }], + credentialTypes: [{ id: "ct-1" }], + unifiSites: [{ id: "site-1" }], + accessToken: "tok", + }); + + expect(mockOptima.company.fetch).toHaveBeenCalledWith("tok", "c1", { + includeAddress: true, + includePrimaryContact: true, + includeAllContacts: true, + }); + }); + + it("handles credential fetch failure gracefully", async () => { + mockCheckPermissions.mockResolvedValueOnce({ + "company.fetch.address": false, + "company.fetch.contacts": false, + }); + mockOptima.company.fetchConfigurations.mockResolvedValueOnce({ data: [] }); + mockOptima.credential.fetchByCompany.mockRejectedValueOnce( + new Error("fail"), + ); + mockOptima.credentialType.fetchMany.mockRejectedValueOnce( + new Error("fail"), + ); + mockOptima.unifi.fetchCompanySites.mockRejectedValueOnce(new Error("fail")); + mockOptima.company.fetch.mockResolvedValueOnce({ data: { id: "c1" } }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + params: { id: "c1" }, + } as any); + + expect(result).toMatchObject({ + credentials: [], + credentialTypes: [], + unifiSites: [], + }); + }); +}); diff --git a/src/routes/companies/[id]/secure-value/+server.spec.ts b/src/routes/companies/[id]/secure-value/+server.spec.ts new file mode 100644 index 0000000..5f9dfa0 --- /dev/null +++ b/src/routes/companies/[id]/secure-value/+server.spec.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + credential: { fetchSecureValue: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET } from "./+server"; + +describe("GET /companies/[id]/secure-value", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + url: new URL("http://localhost/secure-value?credentialId=c1&fieldId=f1"), + }; + await expect(GET(event as any)).rejects.toEqual( + expect.objectContaining({ status: 401 }), + ); + }); + + it("throws 400 when credentialId is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/secure-value?fieldId=f1"), + }; + await expect(GET(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("throws 400 when fieldId is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/secure-value?credentialId=c1"), + }; + await expect(GET(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("fetches secure value successfully", async () => { + mockOptima.credential.fetchSecureValue.mockResolvedValueOnce({ + data: "secret", + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/secure-value?credentialId=c1&fieldId=f1"), + }; + + await GET(event as any); + + expect(mockOptima.credential.fetchSecureValue).toHaveBeenCalledWith( + "tok", + "c1", + "f1", + ); + expect(mockJson).toHaveBeenCalledWith({ data: "secret" }); + }); + + it("throws on API failure", async () => { + mockOptima.credential.fetchSecureValue.mockRejectedValueOnce({ + status: 403, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/secure-value?credentialId=c1&fieldId=f1"), + }; + + await expect(GET(event as any)).rejects.toBeDefined(); + }); +}); diff --git a/src/routes/healthz/+server.spec.ts b/src/routes/healthz/+server.spec.ts new file mode 100644 index 0000000..262da96 --- /dev/null +++ b/src/routes/healthz/+server.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { GET } from "./+server"; + +describe("GET /healthz", () => { + it("returns 200 with status ok", async () => { + const response = await GET({} as any); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ status: "ok" }); + }); + + it("returns JSON content type", async () => { + const response = await GET({} as any); + + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); +}); diff --git a/src/routes/procurement/catalog/+page.server.spec.ts b/src/routes/procurement/catalog/+page.server.spec.ts new file mode 100644 index 0000000..0e589ca --- /dev/null +++ b/src/routes/procurement/catalog/+page.server.spec.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted( + () => ({ + mockOptima: { + procurement: { fetchMany: vi.fn() }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + }), +); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); + +import { load } from "./+page.server"; + +describe("procurement/catalog +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty data when no token", async () => { + const result = await load({ + locals: {}, + url: new URL("http://localhost/procurement/catalog"), + } as any); + + expect(result).toMatchObject({ + items: [], + totalPages: 1, + currentPage: 1, + totalRecords: 0, + search: "", + }); + }); + + it("fetches catalog items with pagination", async () => { + mockOptima.procurement.fetchMany.mockResolvedValueOnce({ + data: [{ id: "item-1" }], + meta: { + pagination: { totalPages: 2, currentPage: 1, totalRecords: 45 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({ + "procurement.catalog.fetch.many": true, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/procurement/catalog?page=1&search=widget"), + } as any); + + expect(result).toMatchObject({ + items: [{ id: "item-1" }], + totalPages: 2, + totalRecords: 45, + search: "widget", + }); + }); + + it("passes includeInactive when param is true", async () => { + mockOptima.procurement.fetchMany.mockResolvedValueOnce({ + data: [], + meta: { + pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({}); + + await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/procurement/catalog?includeInactive=true"), + } as any); + + expect(mockOptima.procurement.fetchMany).toHaveBeenCalledWith( + "tok", + 1, + { search: "", includeInactive: true }, + 30, + ); + }); +}); diff --git a/src/routes/procurement/catalog/linked/+server.spec.ts b/src/routes/procurement/catalog/linked/+server.spec.ts new file mode 100644 index 0000000..eeee293 --- /dev/null +++ b/src/routes/procurement/catalog/linked/+server.spec.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + procurement: { + fetchLinkedItems: vi.fn(), + linkItem: vi.fn(), + unlinkItem: vi.fn(), + }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET, POST } from "./+server"; + +describe("/procurement/catalog/linked", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("GET", () => { + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + url: new URL("http://localhost/linked?id=item-1"), + }; + await expect(GET(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 when id is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/linked"), + }; + await expect(GET(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("fetches linked items", async () => { + mockOptima.procurement.fetchLinkedItems.mockResolvedValueOnce({ + data: [{ id: "linked-1" }], + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/linked?id=item-1"), + }; + + await GET(event as any); + + expect(mockOptima.procurement.fetchLinkedItems).toHaveBeenCalledWith( + "tok", + "item-1", + ); + expect(mockJson).toHaveBeenCalledWith({ data: [{ id: "linked-1" }] }); + }); + }); + + describe("POST", () => { + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + request: { + json: vi.fn().mockResolvedValue({ + action: "link", + identifier: "a", + targetId: "b", + }), + }, + }; + await expect(POST(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 when fields are missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ action: "link" }), + }, + }; + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("links items", async () => { + mockOptima.procurement.linkItem.mockResolvedValueOnce({ ok: true }); + + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ + action: "link", + identifier: "a", + targetId: "b", + }), + }, + }; + + await POST(event as any); + + expect(mockOptima.procurement.linkItem).toHaveBeenCalledWith( + "tok", + "a", + "b", + ); + expect(mockJson).toHaveBeenCalledWith({ ok: true }); + }); + + it("unlinks items", async () => { + mockOptima.procurement.unlinkItem.mockResolvedValueOnce({ ok: true }); + + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ + action: "unlink", + identifier: "a", + targetId: "b", + }), + }, + }; + + await POST(event as any); + + expect(mockOptima.procurement.unlinkItem).toHaveBeenCalledWith( + "tok", + "a", + "b", + ); + }); + + it("throws 400 for invalid action", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + request: { + json: vi.fn().mockResolvedValue({ + action: "destroy", + identifier: "a", + targetId: "b", + }), + }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + }); +}); diff --git a/src/routes/procurement/catalog/search/+server.spec.ts b/src/routes/procurement/catalog/search/+server.spec.ts new file mode 100644 index 0000000..35e2a7e --- /dev/null +++ b/src/routes/procurement/catalog/search/+server.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + procurement: { fetchMany: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET } from "./+server"; + +describe("GET /procurement/catalog/search", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + url: new URL("http://localhost/search?q=widget"), + }; + await expect(GET(event as any)).rejects.toBeDefined(); + }); + + it("returns empty data when query is empty", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/search"), + }; + + GET(event as any); + + expect(mockJson).toHaveBeenCalledWith({ data: [] }); + expect(mockOptima.procurement.fetchMany).not.toHaveBeenCalled(); + }); + + it("searches catalog items", async () => { + mockOptima.procurement.fetchMany.mockResolvedValueOnce({ + data: [{ id: "item-1", name: "Widget" }], + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/search?q=widget"), + }; + + await GET(event as any); + + expect(mockOptima.procurement.fetchMany).toHaveBeenCalledWith( + "tok", + 1, + { search: "widget", includeInactive: true }, + 20, + ); + expect(mockJson).toHaveBeenCalledWith({ + data: [{ id: "item-1", name: "Widget" }], + }); + }); + + it("throws on failure", async () => { + mockOptima.procurement.fetchMany.mockRejectedValueOnce({ + status: 500, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/search?q=widget"), + }; + + await expect(GET(event as any)).rejects.toBeDefined(); + }); +}); diff --git a/src/routes/sales/+page.server.spec.ts b/src/routes/sales/+page.server.spec.ts new file mode 100644 index 0000000..3da81b0 --- /dev/null +++ b/src/routes/sales/+page.server.spec.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted( + () => ({ + mockOptima: { + sales: { + fetchMany: vi.fn(), + fetchOpportunityTypes: vi.fn(), + }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + }), +); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); + +import { load } from "./+page.server"; + +describe("sales +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty data when no token", async () => { + const result = await load({ + locals: {}, + url: new URL("http://localhost/sales"), + } as any); + + expect(result).toMatchObject({ + opportunities: [], + opportunityTypes: [], + totalPages: 1, + currentPage: 1, + totalRecords: 0, + search: "", + includeClosed: true, + }); + }); + + it("fetches opportunities with pagination", async () => { + mockOptima.sales.fetchMany.mockResolvedValueOnce({ + data: [{ id: "opp-1" }], + meta: { + pagination: { totalPages: 3, currentPage: 2, totalRecords: 90 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({ + "sales.opportunity.fetch.many": true, + }); + mockOptima.sales.fetchOpportunityTypes.mockResolvedValueOnce({ + data: ["Type A"], + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/sales?page=2&search=deal"), + } as any); + + expect(mockOptima.sales.fetchMany).toHaveBeenCalledWith( + "tok", + 2, + "deal", + 30, + true, + ); + expect(result).toMatchObject({ + totalPages: 3, + currentPage: 2, + search: "deal", + }); + }); + + it("passes includeClosed=false when param is false", async () => { + mockOptima.sales.fetchMany.mockResolvedValueOnce({ + data: [], + meta: { + pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 }, + }, + }); + mockCheckPermissions.mockResolvedValueOnce({}); + mockOptima.sales.fetchOpportunityTypes.mockResolvedValueOnce({ + data: [], + }); + + await load({ + locals: { session: { accessToken: "tok" } }, + url: new URL("http://localhost/sales?includeClosed=false"), + } as any); + + expect(mockOptima.sales.fetchMany).toHaveBeenCalledWith( + "tok", + 1, + "", + 30, + false, + ); + }); +}); diff --git a/src/routes/sales/opportunities/+page.svelte b/src/routes/sales/opportunities/+page.svelte index 7229157..1b7e0f7 100644 --- a/src/routes/sales/opportunities/+page.svelte +++ b/src/routes/sales/opportunities/+page.svelte @@ -253,6 +253,7 @@ if (t.closedFlag) return "status-closed"; if (t.inactiveFlag) return "status-inactive"; const n = t.name.toLowerCase(); + if (n.includes("cancel")) return "status-canceled"; if (n.includes("future")) return "status-future"; if (n.includes("new")) return "status-new"; if (n.includes("review")) return "status-review"; diff --git a/src/routes/sales/opportunity/[id]/+page.server.spec.ts b/src/routes/sales/opportunity/[id]/+page.server.spec.ts new file mode 100644 index 0000000..08f8e0a --- /dev/null +++ b/src/routes/sales/opportunity/[id]/+page.server.spec.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted( + () => ({ + mockOptima: { + sales: { + fetchOne: vi.fn(), + fetchWorkflowStatus: vi.fn(), + }, + }, + mockCheckPermissions: vi.fn(), + mockHandleApiError: vi.fn(), + }), +); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("$lib/permissions", () => ({ + checkPermissions: mockCheckPermissions, +})); +vi.mock("$lib/optima-api/errorHandler", () => ({ + handleApiError: mockHandleApiError, +})); +// Mock fs/path so we don't write debug files +vi.mock("fs", () => ({ + writeFileSync: vi.fn(), +})); +vi.mock("path", () => ({ + resolve: vi.fn((...args: string[]) => args.join("/")), +})); + +import { load } from "./+page.server"; + +describe("sales/opportunity/[id] +page.server.ts load", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns empty data when no token", async () => { + const result = await load({ + locals: {}, + params: { id: "opp-1" }, + } as any); + + expect(result).toMatchObject({ + opportunity: null, + notes: [], + contacts: [], + products: [], + quotes: [], + accessToken: null, + workflowStatus: null, + }); + }); + + it("loads opportunity with all includes", async () => { + mockOptima.sales.fetchOne.mockResolvedValueOnce({ + data: { + id: "opp-1", + name: "Big Deal", + notes: [{ id: 1 }], + contacts: [{ id: "c1" }], + products: [{ id: "p1" }], + quotes: [{ id: "q1" }], + }, + }); + mockCheckPermissions.mockResolvedValueOnce({ + "sales.opportunity.fetch": true, + }); + mockOptima.sales.fetchWorkflowStatus.mockResolvedValueOnce({ + data: { state: "draft" }, + }); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + } as any); + + expect(mockOptima.sales.fetchOne).toHaveBeenCalledWith("tok", "opp-1", [ + "notes", + "contacts", + "products", + "quotes", + ]); + expect(result).toMatchObject({ + opportunity: expect.objectContaining({ id: "opp-1" }), + notes: [{ id: 1 }], + contacts: [{ id: "c1" }], + products: [{ id: "p1" }], + quotes: [{ id: "q1" }], + accessToken: "tok", + workflowStatus: { state: "draft" }, + }); + }); + + it("handles workflow status fetch failure gracefully", async () => { + mockOptima.sales.fetchOne.mockResolvedValueOnce({ + data: { id: "opp-1" }, + }); + mockCheckPermissions.mockResolvedValueOnce({}); + mockOptima.sales.fetchWorkflowStatus.mockRejectedValueOnce( + new Error("fail"), + ); + + const result = await load({ + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + } as any); + + expect(result).toMatchObject({ + workflowStatus: null, + }); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/+page.server.ts b/src/routes/sales/opportunity/[id]/+page.server.ts index 1f050b5..8023745 100644 --- a/src/routes/sales/opportunity/[id]/+page.server.ts +++ b/src/routes/sales/opportunity/[id]/+page.server.ts @@ -14,11 +14,12 @@ export const load: PageServerLoad = async ({ locals, params }) => { quotes: [], accessToken: null, permissions: {} as PermissionMap, + workflowStatus: null, }; } try { - const [result, permissions] = await Promise.all([ + const [result, permissions, workflowResult] = await Promise.all([ optima.sales.fetchOne(accessToken, params.id, [ "notes", "contacts", @@ -38,8 +39,25 @@ export const load: PageServerLoad = async ({ locals, params }) => { "sales.opportunity.quote.fetch_downloads", "sales.opportunity.view_margin", "sales.opportunity.view_cost", + "sales.opportunity.view_profit", "sales.opportunity.update", + "sales.opportunity.delete", + "sales.opportunity.product.delete", + "sales.opportunity.product.update", + "sales.opportunity.workflow", + "sales.opportunity.finalize", + "sales.opportunity.cancel", + "sales.opportunity.review", + "sales.opportunity.send", + "sales.opportunity.reopen", + "sales.opportunity.win", + "sales.opportunity.lose", + "ui.navigation.reports.view", ]), + optima.sales.fetchWorkflowStatus(accessToken, params.id).catch((err) => { + console.error("[Workflow] Failed to load workflow status:", err); + return null; + }), ]); const { writeFileSync } = await import("fs"); @@ -54,6 +72,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { const contacts = result?.data?.contacts ?? []; const products = result?.data?.products ?? []; const quotes = result?.data?.quotes ?? []; + const workflowStatus = workflowResult?.data ?? null; return { opportunity, @@ -64,6 +83,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { quotes, accessToken, permissions, + workflowStatus, }; } catch (err) { handleApiError(err); diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte index 4c80322..4db36f3 100644 --- a/src/routes/sales/opportunity/[id]/+page.svelte +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -8,10 +8,11 @@ import OpportunitySidebar from "./components/OpportunitySidebar.svelte"; import OverviewTab from "./components/OverviewTab.svelte"; import NotesTab from "./components/NotesTab.svelte"; - import ContactsTab from "./components/ContactsTab.svelte"; import ActivityTab from "./components/ActivityTab.svelte"; import ProductsTab from "./components/ProductsTab.svelte"; import QuotesTab from "./components/QuotesTab.svelte"; + import OpportunityReportsTab from "./components/OpportunityReportsTab.svelte"; + import WorkflowPanel from "./components/WorkflowPanel.svelte"; export let data: PageData; @@ -22,6 +23,21 @@ $: products = data.products; $: quotes = data.quotes ?? []; $: permissions = data.permissions; + $: workflowStatus = data.workflowStatus ?? null; + + // Closed opportunity lockdown – no edits except admin delete + $: 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") + ); + })(); + let localProductSequence: number[] | null = data.opportunity?.productSequence ?? null; @@ -52,17 +68,24 @@ "Products", "Quotes", "Notes", - "Contacts", "Activity", + "Reports", ] as const; 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, - ); + // Hide Reports tab if user lacks reports permission + $: visibleTabs = tabs.filter((t) => { + if ( + t === "Quotes" && + permissions["sales.opportunity.quote.fetch"] === false + ) + return false; + if (t === "Reports" && permissions["ui.navigation.reports.view"] === false) + return false; + return true; + }); // Track whether ProductsTab is in edit mode let productsEditing = false; @@ -84,11 +107,19 @@ // Product to auto-select when switching to Products tab let pendingProductId: number | null = null; + // Quote to auto-select when switching to Quotes tab + let pendingQuoteId: string | null = null; + function handleSelectProduct(e: CustomEvent) { pendingProductId = e.detail; guardedSetTab("Products"); } + function handleViewQuote(e: CustomEvent) { + pendingQuoteId = e.detail; + guardedSetTab("Quotes"); + } + function handleSequenceSaved(e: CustomEvent) { localProductSequence = e.detail; } @@ -97,6 +128,12 @@ products = e.detail; } + function handleQuotesChanged( + e: CustomEvent, + ) { + quotes = e.detail; + } + // Mobile nav state let mobileActiveTab: Tab | null = null; @@ -135,6 +172,8 @@ {isMobile} {mobileActiveTab} {permissions} + {isClosedOpportunity} + {workflowStatus} accessToken={data.accessToken} on:updated={() => invalidateAll()} /> @@ -182,23 +221,6 @@ y2="13" /> - {:else if tab === "Contacts"} - - - {:else if tab === "Quotes"} 0} {notes.length} {/if} - {#if tab === "Contacts" && contacts.length > 0} - {contacts.length} - {/if} {#if tab === "Quotes" && quotes.length > 0} {quotes.length} {/if} @@ -302,14 +321,25 @@ {#if tab === "Notes" && notes.length > 0} {notes.length} {/if} - {#if tab === "Contacts" && contacts.length > 0} - {contacts.length} - {/if} {#if tab === "Quotes" && quotes.length > 0} {quotes.length} {/if} {/each} + + + {#if opportunity && opportunityId} + invalidateAll()} + /> + {/if}
{#if activeTab === "Overview"} @@ -318,7 +348,9 @@ {notes} {contacts} {products} + {permissions} on:selectProduct={handleSelectProduct} + on:switchTab={(e) => guardedSetTab(e.detail as Tab)} /> {:else if activeTab === "Products"} {:else if activeTab === "Notes"} { invalidateAll(); }} /> - {:else if activeTab === "Contacts"} - {:else if activeTab === "Activity"} - + + {:else if activeTab === "Reports"} + {/if}
diff --git a/src/routes/sales/opportunity/[id]/+server.spec.ts b/src/routes/sales/opportunity/[id]/+server.spec.ts new file mode 100644 index 0000000..ea27c05 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/+server.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { deleteOpportunity: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { DELETE } from "./+server"; + +describe("DELETE /sales/opportunity/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { locals: {}, params: { id: "opp-1" } }; + await expect(DELETE(event as any)).rejects.toEqual( + expect.objectContaining({ status: 401 }), + ); + }); + + it("deletes opportunity successfully", async () => { + mockOptima.sales.deleteOpportunity.mockResolvedValueOnce({ success: true }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + }; + + await DELETE(event as any); + + expect(mockOptima.sales.deleteOpportunity).toHaveBeenCalledWith( + "tok", + "opp-1", + ); + expect(mockJson).toHaveBeenCalledWith({ success: true }); + }); + + it("throws error on failure", async () => { + mockOptima.sales.deleteOpportunity.mockRejectedValueOnce({ + status: 404, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + }; + + await expect(DELETE(event as any)).rejects.toBeDefined(); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/+server.ts b/src/routes/sales/opportunity/[id]/+server.ts new file mode 100644 index 0000000..1a71909 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/+server.ts @@ -0,0 +1,21 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** DELETE /sales/opportunity/[id] — delete an opportunity */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + try { + const result = await optima.sales.deleteOpportunity(accessToken, params.id); + return json(result); + } catch (err: unknown) { + console.error("Failed to delete opportunity:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + throw error(status, "Failed to delete opportunity"); + } +}; diff --git a/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte b/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte index 3a2f233..92315ea 100644 --- a/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte @@ -1,8 +1,186 @@
-
+

- Recent Activity + Workflow Activity

-

Activity feed coming soon.

+
+ + {#if isLoading && timelineItems.length === 0} +
+ + Loading activity history... +
+ {:else if loadError} +
+ + + + {loadError} +
+ {:else if timelineItems.length === 0} +
+ + + +

No workflow activity yet.

+
+ {:else} +
+ {#each timelineItems as item, i (item.activity.cwActivityId ?? i)} + {@const act = item.activity} + {@const transition = parseStatusTransition(act.notes)} + {@const open = item.closed != null ? !item.closed : isOpenActivity(act)} +
+ +
+
+ + + +
+ {#if i < timelineItems.length - 1} +
+ {/if} +
+ + +
+
+ + {#if item.optimaType} + + {item.optimaType} + + {/if} + + + {#if transition} + + {transition.from} + + + + {transition.to} + + {/if} + + + {#if open} + Open + {/if} +
+ + + {#if act.name} +

{act.name}

+ {/if} + + + {#if act.notes} +

{act.notes}

+ {/if} + + +
+ + {#if isSystemActivity(act)} + + + + {:else} + + + + {/if} + {assignedDisplay(act)} + + + {#if act.cwDateEntered} + {formatDateTime(act.cwDateEntered)} + {:else if act.dateStart} + {formatDateTime(act.dateStart)} + {/if} + + {#if item.closedAt} + + Closed: {formatDateTime(item.closedAt)} + + {:else if act.closedAt} + + Closed: {formatDateTime(act.closedAt)} + + {:else if act.dateEnd} + + Closed: {formatDateTime(act.dateEnd)} + + {/if} +
+ + + {#if item.quoteId && item.optimaType === "Quote Generated"} + + {/if} +
+
+ {/each} +
+ {/if}
diff --git a/src/routes/sales/opportunity/[id]/components/FinalizeModal.svelte b/src/routes/sales/opportunity/[id]/components/FinalizeModal.svelte new file mode 100644 index 0000000..43c31bd --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/FinalizeModal.svelte @@ -0,0 +1,394 @@ + + +{#if isOpen} + +{/if} diff --git a/src/routes/sales/opportunity/[id]/components/NotesTab.svelte b/src/routes/sales/opportunity/[id]/components/NotesTab.svelte index 097640f..3d71900 100644 --- a/src/routes/sales/opportunity/[id]/components/NotesTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/NotesTab.svelte @@ -8,12 +8,19 @@ export let notes: OpportunityNote[] = []; export let permissions: PermissionMap = {} as PermissionMap; export let opportunityId: string; + export let isClosedOpportunity: boolean = false; const dispatch = createEventDispatcher(); - $: canCreate = permissions["sales.opportunity.note.create"] === true; - $: canUpdate = permissions["sales.opportunity.note.update"] === true; - $: canDelete = permissions["sales.opportunity.note.delete"] === true; + $: canCreate = + !isClosedOpportunity && + permissions["sales.opportunity.note.create"] === true; + $: canUpdate = + !isClosedOpportunity && + permissions["sales.opportunity.note.update"] === true; + $: canDelete = + !isClosedOpportunity && + permissions["sales.opportunity.note.delete"] === true; // ── Compose state ── let composing = false; diff --git a/src/routes/sales/opportunity/[id]/components/OpportunityReportsTab.svelte b/src/routes/sales/opportunity/[id]/components/OpportunityReportsTab.svelte new file mode 100644 index 0000000..8a14fee --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/OpportunityReportsTab.svelte @@ -0,0 +1,335 @@ + + +
+
+

+ + + + Opportunity Report +

+
+ + +
+
+ Current Status + {currentStatusLabel} +
+
+ Workflow Actions + {totalActions} +
+
+ Activity Types + {uniqueTypes.size} +
+ {#if staleDays !== null} +
+ Days Since Activity + {staleDays} +
+ {/if} +
+ + +
+

Workflow History

+ {#if isLoading} +
+ + Loading history... +
+ {:else if loadError} +
{loadError}
+ {:else if historyEntries.length === 0} +

No workflow history for this opportunity.

+ {:else} +
+ + + + + + + + + + + + {#each historyEntries as entry, i (entry.activity.cwActivityId ?? i)} + {@const act = entry.activity} + + + + + + + + {/each} + +
TypeActivityAssigned ToStartedClosed
+ {#if entry.optimaType} + {entry.optimaType} + {:else} + + {/if} + + {#if act.name} + {act.name} + {/if} + {#if act.notes} + {act.notes} + {/if} + {act.assignTo?.name ?? act.cwEnteredBy ?? "System"}{formatDateTime(act.cwDateEntered ?? act.dateStart)} + {#if act.dateEnd} + {formatDateTime(act.dateEnd)} + {:else} + Open + {/if} +
+
+ {/if} +
+
+ + diff --git a/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte b/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte index 0341672..d179c9d 100644 --- a/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte +++ b/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte @@ -1,7 +1,15 @@ + +{#if isOpen} + +{/if} diff --git a/src/routes/sales/opportunity/[id]/components/SendQuoteModal.svelte b/src/routes/sales/opportunity/[id]/components/SendQuoteModal.svelte new file mode 100644 index 0000000..a17e2e0 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/SendQuoteModal.svelte @@ -0,0 +1,377 @@ + + +{#if isOpen} + +{/if} diff --git a/src/routes/sales/opportunity/[id]/components/WorkflowActionModal.svelte b/src/routes/sales/opportunity/[id]/components/WorkflowActionModal.svelte new file mode 100644 index 0000000..3e769bd --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/WorkflowActionModal.svelte @@ -0,0 +1,304 @@ + + +{#if isOpen} + +{/if} diff --git a/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte b/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte new file mode 100644 index 0000000..cd9aabe --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte @@ -0,0 +1,550 @@ + + +{#if workflowStatus && isOptimaStage} +
+ + {#if workflowError} +
+ {workflowError} + +
+ {/if} + + + {#if !isTerminal && allActions.length > 0} +
+ + {#if primaryAction} + {@const isSendGated = + primaryAction.action === "sendQuote" && !!sendQuoteDisabledReason} + {@const isResendGated = + primaryAction.action === "resendQuote" && + !!resendQuoteDisabledReason} + {@const gatedReason = isSendGated + ? sendQuoteDisabledReason + : isResendGated + ? resendQuoteDisabledReason + : null} + {#if gatedReason} + + + + {:else} + + {/if} + {/if} + + + {#if secondaryActions.length > 0 || finalizeWonAction || finalizeLostAction} + {#if primaryAction} + + {/if} + + {#each secondaryActions as action, i (action.action + "-" + i)} + {@const secSendGated = + action.action === "sendQuote" && !!sendQuoteDisabledReason} + {@const secResendGated = + action.action === "resendQuote" && !!resendQuoteDisabledReason} + {@const secGatedReason = secSendGated + ? sendQuoteDisabledReason + : secResendGated + ? resendQuoteDisabledReason + : null} + {#if secGatedReason} + + + + {:else} + + {/if} + {/each} + + {#if finalizeWonAction} + + {/if} + {#if finalizeLostAction} + + {/if} + {/if} +
+ {/if} + + + {#if isTerminal} +
+ + + + This opportunity is finalized and locked. +
+ {/if} +
+{/if} + + + + +{#if activeModal && !isSpecialModal(activeModal)} + {@const action = allActions.find((a) => a.action === activeModal)} + {#if action} + submitAction(action.action, e.detail)} + on:close={closeModal} + /> + {/if} +{/if} + + +{#if activeModal === "sendQuote" || activeModal === "resendQuote"} + submitAction(activeModal ?? "sendQuote", e.detail)} + on:close={closeModal} + /> +{/if} + + +{#if activeModal === "reviewDecision"} + submitAction("reviewDecision", e.detail)} + on:sendQuote={() => { + activeModal = "sendQuote"; + }} + on:close={closeModal} + /> +{/if} + + +{#if activeModal === "finalize"} + {@const wonAction = finalizeWonAction} + {@const lostAction = finalizeLostAction} + submitAction("finalize", e.detail)} + on:close={closeModal} + /> +{/if} diff --git a/src/routes/sales/opportunity/[id]/notes/+server.spec.ts b/src/routes/sales/opportunity/[id]/notes/+server.spec.ts new file mode 100644 index 0000000..f27c653 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/notes/+server.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { createNote: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { POST } from "./+server"; + +describe("POST /sales/opportunity/[id]/notes", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + params: { id: "opp-1" }, + request: { + json: vi.fn().mockResolvedValue({ text: "hello" }), + }, + }; + await expect(POST(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 when text is empty", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { + json: vi.fn().mockResolvedValue({ text: " " }), + }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("creates note successfully", async () => { + const created = { id: 1, text: "A note" }; + mockOptima.sales.createNote.mockResolvedValueOnce(created); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { + json: vi.fn().mockResolvedValue({ text: "A note", flagged: true }), + }, + }; + + await POST(event as any); + + expect(mockOptima.sales.createNote).toHaveBeenCalledWith("tok", "opp-1", { + text: "A note", + flagged: true, + }); + expect(mockJson).toHaveBeenCalledWith(created, { status: 201 }); + }); + + it("defaults flagged to false", async () => { + mockOptima.sales.createNote.mockResolvedValueOnce({ id: 1 }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { + json: vi.fn().mockResolvedValue({ text: "Note" }), + }, + }; + + await POST(event as any); + + expect(mockOptima.sales.createNote).toHaveBeenCalledWith("tok", "opp-1", { + text: "Note", + flagged: false, + }); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.spec.ts b/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.spec.ts new file mode 100644 index 0000000..8dad25c --- /dev/null +++ b/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.spec.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { + updateNote: vi.fn(), + deleteNote: vi.fn(), + }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { PATCH, DELETE } from "./+server"; + +describe("/sales/opportunity/[id]/notes/[noteId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("PATCH", () => { + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + params: { id: "opp-1", noteId: "5" }, + request: { json: vi.fn().mockResolvedValue({ text: "updated" }) }, + }; + await expect(PATCH(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 for invalid noteId", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "abc" }, + request: { json: vi.fn().mockResolvedValue({ text: "updated" }) }, + }; + await expect(PATCH(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("throws 400 when neither text nor flagged provided", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "5" }, + request: { json: vi.fn().mockResolvedValue({}) }, + }; + await expect(PATCH(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("updates note text", async () => { + mockOptima.sales.updateNote.mockResolvedValueOnce({ id: 5 }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "5" }, + request: { + json: vi.fn().mockResolvedValue({ text: "updated text" }), + }, + }; + + await PATCH(event as any); + + expect(mockOptima.sales.updateNote).toHaveBeenCalledWith( + "tok", + "opp-1", + 5, + { text: "updated text" }, + ); + }); + + it("updates note flagged status", async () => { + mockOptima.sales.updateNote.mockResolvedValueOnce({ id: 5 }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "5" }, + request: { + json: vi.fn().mockResolvedValue({ flagged: true }), + }, + }; + + await PATCH(event as any); + + expect(mockOptima.sales.updateNote).toHaveBeenCalledWith( + "tok", + "opp-1", + 5, + { flagged: true }, + ); + }); + }); + + describe("DELETE", () => { + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + params: { id: "opp-1", noteId: "5" }, + }; + await expect(DELETE(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 for invalid noteId", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "abc" }, + }; + await expect(DELETE(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("deletes note successfully", async () => { + mockOptima.sales.deleteNote.mockResolvedValueOnce({ success: true }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1", noteId: "5" }, + }; + + await DELETE(event as any); + + expect(mockOptima.sales.deleteNote).toHaveBeenCalledWith( + "tok", + "opp-1", + 5, + ); + expect(mockJson).toHaveBeenCalledWith({ success: true }); + }); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/types.ts b/src/routes/sales/opportunity/[id]/types.ts index 3560ec4..e58bd2e 100644 --- a/src/routes/sales/opportunity/[id]/types.ts +++ b/src/routes/sales/opportunity/[id]/types.ts @@ -1,6 +1,32 @@ import type { SalesOpportunity } from "$lib/optima-api/modules/sales"; +import type { + CommittedQuote, + WorkflowStatusResponse, + WorkflowHistoryResponse, + WorkflowStatusKey, +} from "$lib/optima-api/modules/sales"; +import { + WORKFLOW_STATUS_IDS, + STATUS_ID_TO_KEY, + WORKFLOW_STATUS_LABELS, + TERMINAL_STATUSES, + REOPENABLE_STATUSES, +} from "$lib/optima-api/modules/sales"; import type { PermissionMap } from "$lib/permissions"; +export { + WORKFLOW_STATUS_IDS, + STATUS_ID_TO_KEY, + WORKFLOW_STATUS_LABELS, + TERMINAL_STATUSES, + REOPENABLE_STATUSES, +}; +export type { + WorkflowStatusResponse, + WorkflowHistoryResponse, + WorkflowStatusKey, +}; + export interface OpportunityForecast { id: number; forecastType?: string; @@ -113,8 +139,10 @@ export interface PageData { notes: OpportunityNote[]; contacts: OpportunityContact[]; products: OpportunityProduct[]; + quotes: CommittedQuote[]; accessToken: string | null; permissions: PermissionMap; + workflowStatus: WorkflowStatusResponse | null; } export function opportunityInitials(name: string): string { @@ -158,36 +186,61 @@ const STATUS_TIER: Record = (() => { const map: Record = {}; // FutureLead (id 51) + equivalencies for (const id of [51, 35, 36]) map[id] = "status-future"; - // New (id 24) + equivalencies - for (const id of [24, 1, 13, 37]) map[id] = "status-new"; + // PendingNew (id 37) + map[37] = "status-pending-new"; + // New (id 24) + equivalencies (37 removed — now its own tier) + for (const id of [24, 1, 13]) 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 + // QuoteSent (id 43) + map[43] = "status-quote-sent"; + // ConfirmedQuote (id 57) + map[57] = "status-quote-confirmed"; + // PendingSent (id 60) + map[60] = "status-pending-sent"; + // PendingRevision (id 61) + map[61] = "status-pending-revision"; + // Active (id 58) + equivalencies (43, 57 removed — now own tiers) 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, + 58, 9, 15, 16, 17, 18, 19, 20, 25, 38, 39, 40, 42, 44, 45, 46, 47, 48, 52, + 55, ]) 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"; + // PendingWon (id 49) + map[49] = "status-pending-won"; + // Won (id 29) + equivalencies (49 removed — now own tier) + for (const id of [29, 2]) map[id] = "status-won"; + // PendingLost (id 50) + map[50] = "status-pending-lost"; + // Lost (id 53) + equivalencies (50 removed — now own tier) + for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34]) map[id] = "status-lost"; + // Canceled (id 59) + map[59] = "status-canceled"; return map; })(); /** Canonical display name for each tier */ const CANONICAL_NAMES: Record = { 51: "FutureLead", + 37: "Pending New", 24: "New", 56: "Internal Review", + 43: "Quote Sent", + 57: "Confirmed Quote", + 60: "Pending Sent", + 61: "Pending Revision", 58: "Active", + 49: "Pending Won", 29: "Won", + 50: "Pending Lost", 53: "Lost", + 59: "Canceled", }; /** IDs that are canonical (not equivalency-mapped) */ -const CANONICAL_IDS = new Set([51, 24, 56, 58, 29, 53]); +const CANONICAL_IDS = new Set([ + 51, 37, 24, 56, 43, 57, 60, 61, 58, 49, 29, 50, 53, 59, +]); export function statusColorClass(opportunity: SalesOpportunity): string { if (opportunity.closedFlag) { diff --git a/src/routes/sales/opportunity/[id]/workflow/+server.spec.ts b/src/routes/sales/opportunity/[id]/workflow/+server.spec.ts new file mode 100644 index 0000000..8379152 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/workflow/+server.spec.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { + fetchWorkflowStatus: vi.fn(), + dispatchWorkflowAction: vi.fn(), + }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET, POST } from "./+server"; + +describe("/sales/opportunity/[id]/workflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + describe("GET", () => { + it("throws 401 when no access token", async () => { + const event = { locals: {}, params: { id: "opp-1" } }; + await expect(GET(event as any)).rejects.toBeDefined(); + }); + + it("returns workflow status", async () => { + mockOptima.sales.fetchWorkflowStatus.mockResolvedValueOnce({ + data: { state: "draft" }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + }; + + await GET(event as any); + + expect(mockOptima.sales.fetchWorkflowStatus).toHaveBeenCalledWith( + "tok", + "opp-1", + ); + expect(mockJson).toHaveBeenCalledWith({ data: { state: "draft" } }); + }); + + it("throws on failure", async () => { + mockOptima.sales.fetchWorkflowStatus.mockRejectedValueOnce({ + status: 500, + response: { data: { message: "Internal error" } }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + }; + + await expect(GET(event as any)).rejects.toBeDefined(); + }); + }); + + describe("POST", () => { + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + params: { id: "opp-1" }, + request: { json: vi.fn().mockResolvedValue({ action: "approve" }) }, + }; + await expect(POST(event as any)).rejects.toBeDefined(); + }); + + it("throws 400 when action is missing", async () => { + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { json: vi.fn().mockResolvedValue({}) }, + }; + + await expect(POST(event as any)).rejects.toEqual( + expect.objectContaining({ status: 400 }), + ); + }); + + it("dispatches workflow action successfully", async () => { + mockOptima.sales.dispatchWorkflowAction.mockResolvedValueOnce({ + data: { state: "approved" }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { + json: vi + .fn() + .mockResolvedValue({ action: "approve", payload: { note: "ok" } }), + }, + }; + + await POST(event as any); + + expect(mockOptima.sales.dispatchWorkflowAction).toHaveBeenCalledWith( + "tok", + "opp-1", + "approve", + { note: "ok" }, + ); + expect(mockJson).toHaveBeenCalledWith({ data: { state: "approved" } }); + }); + + it("returns error response data on workflow failure", async () => { + mockOptima.sales.dispatchWorkflowAction.mockRejectedValueOnce({ + status: 422, + response: { + data: { + status: 422, + message: "Cannot transition", + errors: ["Invalid state"], + }, + }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + request: { + json: vi.fn().mockResolvedValue({ action: "approve" }), + }, + }; + + await POST(event as any); + + expect(mockJson).toHaveBeenCalledWith( + { + status: 422, + message: "Cannot transition", + errors: ["Invalid state"], + }, + { status: 422 }, + ); + }); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/workflow/+server.ts b/src/routes/sales/opportunity/[id]/workflow/+server.ts new file mode 100644 index 0000000..5e0f4e5 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/workflow/+server.ts @@ -0,0 +1,72 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** GET /sales/opportunity/[id]/workflow — fetch workflow status */ +export const GET: RequestHandler = async ({ params, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + try { + const result = await optima.sales.fetchWorkflowStatus( + accessToken, + params.id, + ); + return json(result); + } catch (err: unknown) { + console.error("[Workflow] Failed to fetch status:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + const message = + err && typeof err === "object" && "response" in err + ? ((err as { response?: { data?: { message?: string } } }).response + ?.data?.message ?? "Failed to fetch workflow status") + : "Failed to fetch workflow status"; + throw error(status, message); + } +}; + +/** POST /sales/opportunity/[id]/workflow — dispatch workflow action */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + const body = await request.json(); + if (!body.action) throw error(400, "Action is required"); + + try { + const result = await optima.sales.dispatchWorkflowAction( + accessToken, + params.id, + body.action, + body.payload ?? {}, + ); + return json(result); + } catch (err: unknown) { + console.error("[Workflow] Action failed:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + + // Extract the error response data for workflow failures + let responseData: Record | undefined; + if (err && typeof err === "object" && "response" in err) { + const axiosErr = err as { + response?: { data?: Record; status?: number }; + }; + responseData = axiosErr.response?.data; + } + + // Return the full workflow error response so the UI can display it + if (responseData) { + return json(responseData, { + status: (responseData.status as number) ?? status, + }); + } + + throw error(status, "Workflow action failed"); + } +}; diff --git a/src/routes/sales/opportunity/[id]/workflow/history/+server.spec.ts b/src/routes/sales/opportunity/[id]/workflow/history/+server.spec.ts new file mode 100644 index 0000000..d9c501e --- /dev/null +++ b/src/routes/sales/opportunity/[id]/workflow/history/+server.spec.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({ + mockOptima: { + sales: { fetchWorkflowHistory: vi.fn() }, + }, + mockJson: vi.fn((data, init?) => { + return new Response(JSON.stringify(data), { + status: init?.status ?? 200, + }); + }), + mockError: vi.fn((status: number, message: string) => { + throw { status, body: { message } }; + }), +})); + +vi.mock("$lib", () => ({ optima: mockOptima })); +vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError })); + +import { GET } from "./+server"; + +describe("GET /sales/opportunity/[id]/workflow/history", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("throws 401 when no access token", async () => { + const event = { + locals: {}, + params: { id: "opp-1" }, + url: new URL("http://localhost/workflow/history"), + }; + + await expect(GET(event as any)).rejects.toBeDefined(); + }); + + it("fetches workflow history with type param", async () => { + mockOptima.sales.fetchWorkflowHistory.mockResolvedValueOnce({ + data: [{ action: "approve" }], + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + url: new URL("http://localhost/workflow/history?type=approval"), + }; + + await GET(event as any); + + expect(mockOptima.sales.fetchWorkflowHistory).toHaveBeenCalledWith( + "tok", + "opp-1", + "approval", + ); + expect(mockJson).toHaveBeenCalledWith({ + data: [{ action: "approve" }], + }); + }); + + it("passes undefined when type param is absent", async () => { + mockOptima.sales.fetchWorkflowHistory.mockResolvedValueOnce({ data: [] }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + url: new URL("http://localhost/workflow/history"), + }; + + await GET(event as any); + + expect(mockOptima.sales.fetchWorkflowHistory).toHaveBeenCalledWith( + "tok", + "opp-1", + undefined, + ); + }); + + it("throws on failure with API error message", async () => { + mockOptima.sales.fetchWorkflowHistory.mockRejectedValueOnce({ + status: 500, + response: { data: { message: "Server broke" } }, + }); + + const event = { + locals: { session: { accessToken: "tok" } }, + params: { id: "opp-1" }, + url: new URL("http://localhost/workflow/history"), + }; + + await expect(GET(event as any)).rejects.toBeDefined(); + }); +}); diff --git a/src/routes/sales/opportunity/[id]/workflow/history/+server.ts b/src/routes/sales/opportunity/[id]/workflow/history/+server.ts new file mode 100644 index 0000000..82be481 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/workflow/history/+server.ts @@ -0,0 +1,32 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** GET /sales/opportunity/[id]/workflow/history — fetch workflow activity history */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + const type = url.searchParams.get("type") ?? undefined; + + try { + const result = await optima.sales.fetchWorkflowHistory( + accessToken, + params.id, + type, + ); + return json(result); + } catch (err: unknown) { + console.error("[Workflow] Failed to fetch history:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + const message = + err && typeof err === "object" && "response" in err + ? ((err as { response?: { data?: { message?: string } } }).response + ?.data?.message ?? "Failed to fetch workflow history") + : "Failed to fetch workflow history"; + throw error(status, message); + } +}; diff --git a/src/styles/sales/opportunitydetail.css b/src/styles/sales/opportunitydetail.css index f792070..6b3b76d 100644 --- a/src/styles/sales/opportunitydetail.css +++ b/src/styles/sales/opportunitydetail.css @@ -477,11 +477,95 @@ color: var(--accent-color, #3b82f6); font-weight: 500; } +.opp-edit-dropdown-item-none { + color: var(--text-tertiary, #9ca3af); + font-style: italic; +} +.opp-edit-dropdown-item.selected .opp-edit-dropdown-item-none { + color: var(--accent-color, #3b82f6); + font-style: normal; +} .opp-edit-dropdown-item svg { flex-shrink: 0; color: var(--accent-color, #3b82f6); } +/* Stacked item layout (name + subtitle) */ +.opp-edit-dropdown-item-stack { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; + min-width: 0; + overflow: hidden; +} +.opp-edit-dropdown-item-sub { + font-size: 10.5px; + color: var(--text-tertiary, #9ca3af); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.opp-edit-dropdown-item-badge { + font-size: 9.5px; + font-weight: 600; + color: var(--accent-color, #3b82f6); + background: color-mix(in srgb, var(--accent-color, #3b82f6) 12%, transparent); + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} + +/* Site chip (current site with clear button) */ +.opp-edit-site-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border-color, #d1d5db); + background: var(--surface-secondary, #f9fafb); + min-height: 32px; +} +.opp-edit-site-chip.opp-edit-site-empty { + border-style: dashed; +} +.opp-edit-site-name { + flex: 1; + font-size: 12.5px; + color: var(--text-primary, #1f2937); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.opp-edit-site-name.placeholder { + color: var(--text-tertiary, #9ca3af); + font-style: italic; +} +.opp-edit-site-clear { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: var(--text-tertiary, #9ca3af); + color: white; + cursor: pointer; + padding: 0; + transition: background 0.15s ease; +} +.opp-edit-site-clear:hover { + background: var(--text-secondary, #6b7280); +} +.opp-edit-site-clear:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Empty / loading placeholder */ .opp-edit-dropdown-empty { padding: 16px 10px; @@ -1057,6 +1141,8 @@ background: var(--bg-surface); position: relative; z-index: 10; + overflow: visible; + min-width: 0; } .tab-btn { @@ -1435,6 +1521,149 @@ font-weight: 600; } +/* Activity dot colors for overview timeline */ +.ov-timeline-dot.ov-dot-created { + background: #ca8a04; + border-color: #ca8a04; +} +.ov-timeline-dot.ov-dot-setup { + background: #6b7280; + border-color: #6b7280; +} +.ov-timeline-dot.ov-dot-review { + background: #ea580c; + border-color: #ea580c; +} +.ov-timeline-dot.ov-dot-sent { + background: #2563eb; + border-color: #2563eb; +} +.ov-timeline-dot.ov-dot-confirmed { + background: #059669; + border-color: #059669; +} +.ov-timeline-dot.ov-dot-sent-confirmed { + background: #0891b2; + border-color: #0891b2; +} +.ov-timeline-dot.ov-dot-revision { + background: #d97706; + border-color: #d97706; +} +.ov-timeline-dot.ov-dot-finalized { + background: #7c3aed; + border-color: #7c3aed; +} +.ov-timeline-dot.ov-dot-converted { + background: #db2777; + border-color: #db2777; +} +.ov-timeline-dot.ov-dot-generated { + background: #0d9488; + border-color: #0d9488; +} +.ov-timeline-dot.ov-dot-default { + background: var(--text-muted, #aaa); + border-color: var(--text-muted, #aaa); +} + +/* Activity label text colors for overview timeline */ +.ov-timeline-label.ov-text-created { + color: #ca8a04; +} +.ov-timeline-label.ov-text-setup { + color: #6b7280; +} +.ov-timeline-label.ov-text-review { + color: #ea580c; +} +.ov-timeline-label.ov-text-sent { + color: #2563eb; +} +.ov-timeline-label.ov-text-confirmed { + color: #059669; +} +.ov-timeline-label.ov-text-sent-confirmed { + color: #0891b2; +} +.ov-timeline-label.ov-text-revision { + color: #d97706; +} +.ov-timeline-label.ov-text-finalized { + color: #7c3aed; +} +.ov-timeline-label.ov-text-converted { + color: #db2777; +} +.ov-timeline-label.ov-text-generated { + color: #0d9488; +} + +/* Collapsed timeline gap button */ +.ov-timeline-gap { + display: flex; + align-items: center; + gap: 6px; + padding: 0 0 20px 24px; + margin: 0; + border: none; + background: none; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + transition: all 0.15s; + position: relative; +} +.ov-timeline-gap::before { + content: ""; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background: repeating-linear-gradient( + to bottom, + var(--border-subtle) 0px, + var(--border-subtle) 3px, + transparent 3px, + transparent 6px + ); +} +.ov-gap-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 10px; + background: var(--bg-surface-raised, rgba(107, 114, 128, 0.06)); + border: 1px dashed var(--border-subtle); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + transition: all 0.15s; +} +.ov-gap-arrow { + opacity: 0; + transform: translateX(-2px); + transition: all 0.15s; + color: var(--accent); +} +.ov-timeline-gap:hover .ov-gap-pill { + color: var(--accent); + border-color: var(--accent); + background: rgba(99, 102, 241, 0.06); +} +.ov-timeline-gap:hover .ov-gap-arrow { + opacity: 1; + transform: translateX(0); +} +[data-theme="dark"] .ov-gap-pill { + background: rgba(255, 255, 255, 0.04); +} +[data-theme="dark"] .ov-timeline-gap:hover .ov-gap-pill { + background: rgba(99, 102, 241, 0.1); +} + /* ── Quick Stats ── */ .ov-quick-stats { display: flex; @@ -1674,7 +1903,7 @@ /* ── Product Hover Popover ── */ .ov-product-popover { - position: absolute; + position: fixed; z-index: 50; transform: translateY(-100%); width: 280px; @@ -1931,6 +2160,121 @@ display: none; } +/* ── Sidebar header actions ── */ +.opp-sidebar-header-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* ── Delete button (view mode) ── */ +.opp-delete-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted, #9ca3af); + cursor: pointer; + transition: all 0.15s ease; +} +.opp-delete-btn:hover { + background: rgba(239, 68, 68, 0.08); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.2); +} + +/* ── Delete confirmation modal ── */ +.opp-delete-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + animation: opp-fade-in 0.15s ease; +} +@keyframes opp-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.opp-delete-modal { + width: 380px; + max-width: 90vw; + padding: 24px; + border-radius: 14px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + animation: opp-modal-in 0.2s ease; +} +@keyframes opp-modal-in { + from { + opacity: 0; + transform: scale(0.95) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} +.opp-delete-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} +.opp-delete-msg { + margin: 0 0 18px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} +.opp-delete-msg strong { + color: var(--text-primary); +} +.opp-delete-error { + font-size: 12px; + color: #ef4444; + background: color-mix(in srgb, #ef4444 10%, transparent); + border-radius: 6px; + padding: 8px 10px; + margin: 0 0 12px; +} +.opp-delete-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.opp-delete-confirm-btn { + padding: 6px 18px; + border: none; + border-radius: 7px; + background: #ef4444; + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.opp-delete-confirm-btn:hover:not(:disabled) { + background: #dc2626; +} +.opp-delete-confirm-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ═══════════════════════════════════════════════════ Forecasts Tab ═══════════════════════════════════════════════════ */ @@ -3486,6 +3830,7 @@ display: flex; flex-direction: column; overflow: hidden; + min-height: 0; padding: 16px; gap: 12px; } @@ -3639,6 +3984,17 @@ padding: 40px 20px; } +.tab-empty-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.empty-add-btn { + margin-top: 4px; +} + /* ═══════════════════════════════════════════════════ Narrow desktop — Forecast summary table tightening ═══════════════════════════════════════════════════ */ @@ -3940,3 +4296,1771 @@ flex-direction: column; } } + +/* ══════════════════════════════════════════════════════════════════ + WORKFLOW PANEL + ══════════════════════════════════════════════════════════════════ */ + +.wf-panel { + padding: 12px 0; +} + +/* ── Inline variant (inside tab bar) ── */ +.wf-panel-inline { + display: flex; + align-items: center; + gap: 8px; + padding: 0 0 0 8px; + margin-left: auto; + flex-shrink: 1; + min-width: 0; + overflow: visible; +} + +.wf-panel-inline .wf-status-section { + margin-bottom: 0; + flex-shrink: 0; +} + +.wf-panel-inline .wf-status-row { + gap: 6px; +} + +.wf-panel-inline .wf-stale-note { + display: none; +} + +.wf-panel-inline .wf-actions { + margin-top: 0; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +.wf-panel-inline .wf-action-btn { + padding: 4px 10px; + font-size: 11px; +} + +.wf-panel-inline .wf-error-banner { + margin-bottom: 0; + padding: 4px 8px; + font-size: 11px; + max-width: 260px; +} + +.wf-panel-inline .wf-terminal-notice { + margin-top: 0; + white-space: nowrap; +} + +/* ── Status Row ── */ +.wf-status-section { + margin-bottom: 10px; +} + +.wf-status-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.wf-status-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: + background 0.2s, + color 0.2s; +} + +/* Status variants */ +.wf-status-badge.wf-status-pending-new { + background: rgba(20, 184, 166, 0.1); + color: #0d9488; + border: 1px dashed #0d9488; +} +.wf-status-badge.wf-status-new { + background: rgba(20, 184, 166, 0.12); + color: #0d9488; +} +.wf-status-badge.wf-status-review { + background: rgba(245, 158, 11, 0.12); + color: #d97706; +} +.wf-status-badge.wf-status-quote-sent { + background: rgba(59, 130, 246, 0.12); + color: #2563eb; +} +.wf-status-badge.wf-status-quote-confirmed { + background: rgba(16, 185, 129, 0.12); + color: #059669; +} +.wf-status-badge.wf-status-active { + background: rgba(34, 197, 94, 0.12); + color: #16a34a; +} +.wf-status-badge.wf-status-pending-sent { + background: rgba(59, 130, 246, 0.1); + color: #2563eb; + border: 1px dashed #2563eb; +} +.wf-status-badge.wf-status-pending-revision { + background: rgba(245, 158, 11, 0.1); + color: #d97706; + border: 1px dashed #d97706; +} +.wf-status-badge.wf-status-pending-won { + background: rgba(37, 99, 235, 0.1); + color: #2563eb; + border: 1px dashed #2563eb; +} +.wf-status-badge.wf-status-won { + background: rgba(37, 99, 235, 0.14); + color: #2563eb; +} +.wf-status-badge.wf-status-pending-lost { + background: rgba(220, 38, 38, 0.1); + color: #dc2626; + border: 1px dashed #dc2626; +} +.wf-status-badge.wf-status-lost { + background: rgba(220, 38, 38, 0.12); + color: #dc2626; +} +.wf-status-badge.wf-status-canceled { + background: rgba(107, 114, 128, 0.12); + color: #6b7280; +} +.wf-status-badge.wf-status-default { + background: rgba(107, 114, 128, 0.1); + color: #6b7280; +} + +/* Pending pulse animation */ +.wf-status-badge.wf-pending { + animation: wfPendingPulse 2s ease-in-out infinite; +} + +@keyframes wfPendingPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.65; + } +} + +/* Terminal locked styling */ +.wf-status-badge.wf-terminal { + opacity: 0.85; +} + +/* ── Cold / Stale Indicators ── */ +.wf-cold-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: rgba(147, 197, 253, 0.15); + color: #3b82f6; +} + +.wf-stale-note { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +/* ── Terminal Notice ── */ +.wf-terminal-notice { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); + font-style: italic; + margin-top: 4px; +} + +.wf-terminal-notice svg { + flex-shrink: 0; +} + +/* ── Error Banner ── */ +.wf-error-banner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(220, 38, 38, 0.06); + border: 1px solid rgba(220, 38, 38, 0.15); + margin-bottom: 8px; +} + +.wf-error-text { + flex: 1; + font-size: 12px; + color: #dc2626; + line-height: 1.4; +} + +.wf-error-dismiss { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: #dc2626; + opacity: 0.6; + transition: opacity 0.15s; + flex-shrink: 0; +} + +.wf-error-dismiss:hover { + opacity: 1; +} + +/* ── Action Buttons ── */ +.wf-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.wf-action-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border-radius: 7px; + font-size: 11px; + font-weight: 600; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.wf-action-btn:hover { + transform: translateY(-1px); +} + +.wf-action-btn:active { + transform: translateY(0); +} + +.wf-action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +/* Action button variants */ + +/* Primary — filled accent, the main forward action */ +.wf-action-btn.wf-btn-primary { + background: #2563eb; + border-color: #2563eb; + color: #fff; + box-shadow: 0 1px 2px rgba(37, 99, 235, 0.25); +} +.wf-action-btn.wf-btn-primary:hover { + background: #1d4ed8; + border-color: #1d4ed8; + box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3); +} + +/* Ghost — subtle text-only for secondary actions */ +.wf-action-btn.wf-btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-secondary, #6b7280); +} +.wf-action-btn.wf-btn-ghost:hover { + background: var(--bg-inset, #f5f5f5); + border-color: var(--border-subtle, #e5e5e5); + color: var(--text-primary, #1a1a1a); +} + +/* Ghost-danger — cancel / finalize lost */ +.wf-action-btn.wf-btn-ghost.wf-ghost-danger { + color: #dc2626; +} +.wf-action-btn.wf-btn-ghost.wf-ghost-danger:hover { + background: rgba(220, 38, 38, 0.06); + border-color: rgba(220, 38, 38, 0.12); + color: #b91c1c; +} + +/* Ghost-success — finalize won */ +.wf-action-btn.wf-btn-ghost.wf-ghost-success { + color: #16a34a; +} +.wf-action-btn.wf-btn-ghost.wf-ghost-success:hover { + background: rgba(34, 197, 94, 0.06); + border-color: rgba(34, 197, 94, 0.12); + color: #15803d; +} + +/* Action group separator */ +.wf-action-sep { + width: 1px; + height: 16px; + background: var(--border-subtle, #e5e5e5); + flex-shrink: 0; + align-self: center; +} + +.wf-action-hint { + font-size: 11px; + color: var(--text-muted, #888); + white-space: nowrap; + align-self: center; +} + +/* Tooltip wrapper for disabled action buttons */ +.wf-tooltip-wrap { + position: relative; + display: inline-flex; +} + +.wf-tooltip-wrap::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) scale(0.95); + padding: 5px 10px; + border-radius: 6px; + background: var(--bg-tooltip, #1e1e1e); + color: #f0f0f0; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: + opacity 0.15s, + transform 0.15s; + z-index: 9999; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.wf-tooltip-wrap::before { + content: ""; + position: absolute; + top: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-bottom-color: var(--bg-tooltip, #1e1e1e); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + z-index: 9999; +} + +.wf-tooltip-wrap:hover::after { + opacity: 1; + transform: translateX(-50%) scale(1); +} + +.wf-tooltip-wrap:hover::before { + opacity: 1; +} + +[data-theme="dark"] .wf-tooltip-wrap::after { + background: #333; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +[data-theme="dark"] .wf-tooltip-wrap::before { + border-bottom-color: #333; +} + +/* ══════════════════════════════════════════════════════════════════ + WORKFLOW MODALS (shared) + ══════════════════════════════════════════════════════════════════ */ + +.wf-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: wfFadeIn 0.2s ease; +} + +@keyframes wfFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.wf-modal { + background: var(--bg-surface, #ffffff); + border: 1px solid var(--border-subtle, #e5e5e5); + border-radius: 14px; + box-shadow: + 0 24px 80px -12px rgba(0, 0, 0, 0.28), + 0 0 0 1px rgba(0, 0, 0, 0.03); + width: 90%; + max-width: 460px; + max-height: 88vh; + display: flex; + flex-direction: column; + animation: wfSlideUp 0.25s cubic-bezier(0.32, 0.72, 0, 1); + overflow: hidden; +} + +.wf-modal.wf-modal-wide { + max-width: 540px; +} + +@keyframes wfSlideUp { + from { + transform: scale(0.96) translateY(8px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +.wf-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border-subtle, #e5e5e5); +} + +.wf-modal-header.wf-header-accent { + border-bottom-color: rgba(139, 92, 246, 0.25); + background: linear-gradient( + 135deg, + rgba(139, 92, 246, 0.04) 0%, + rgba(59, 130, 246, 0.03) 100% + ); +} + +.wf-modal-header.wf-header-won { + border-bottom-color: rgba(37, 99, 235, 0.25); + background: linear-gradient( + 135deg, + rgba(59, 130, 246, 0.04) 0%, + transparent 100% + ); +} + +.wf-modal-header.wf-header-lost { + border-bottom-color: rgba(220, 38, 38, 0.25); + background: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.04) 0%, + transparent 100% + ); +} + +.wf-modal-header.wf-destructive { + border-bottom-color: rgba(220, 38, 38, 0.25); + background: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.04) 0%, + transparent 100% + ); +} + +.wf-modal-title { + font-size: 16px; + font-weight: 650; + color: var(--text-primary, #1a1a1a); + margin: 0; + letter-spacing: -0.01em; +} + +.wf-modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: none; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + color: var(--text-muted, #888); + transition: all 0.15s; +} + +.wf-modal-close:hover { + color: var(--text-primary, #1a1a1a); + background: var(--card-hover-bg, #f5f5f5); + border-color: var(--border-subtle, #e5e5e5); +} + +.wf-modal-body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; +} + +.wf-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 14px 24px; + border-top: 1px solid var(--border-subtle, #e5e5e5); + flex-shrink: 0; +} + +/* ── Form Fields ── */ +.wf-field-group { + margin-bottom: 16px; +} + +.wf-field-group:last-child { + margin-bottom: 0; +} + +.wf-time-row { + display: flex; + gap: 12px; +} + +.wf-time-row .wf-time-field { + flex: 1; + min-width: 0; +} + +.wf-time-row .wf-time-field .wf-input { + width: 100%; +} + +.wf-field-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #666); + margin-bottom: 6px; + letter-spacing: 0.01em; +} + +.wf-required { + color: #ef4444; + font-weight: 700; + margin-left: 2px; +} + +.wf-optional { + font-weight: 400; + color: var(--text-muted); + margin-left: 4px; + font-size: 11px; +} + +/* ── Time Entry Section ── */ +.wf-time-section { + margin-top: 12px; + border-top: 1px solid var(--border-subtle, #eee); + padding-top: 12px; +} + +.wf-time-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-muted, #888); + user-select: none; + transition: color 0.15s; +} + +.wf-time-toggle:hover { + color: var(--text-secondary, #666); +} + +.wf-time-toggle svg { + flex-shrink: 0; + opacity: 0.7; +} + +.wf-time-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 30px; + height: 16px; + background: var(--border-strong, #ccc); + border: none; + border-radius: 8px; + position: relative; + cursor: pointer; + margin: 0; + flex-shrink: 0; + outline: none; + transition: background 0.2s; +} + +.wf-time-toggle input[type="checkbox"]:focus, +.wf-time-toggle input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: none; +} + +.wf-time-toggle input[type="checkbox"]::after { + content: ""; + position: absolute; + top: 50%; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + transform: translateY(-50%); + transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wf-time-toggle input[type="checkbox"]:checked { + background: #3b82f6; +} + +.wf-time-toggle input[type="checkbox"]:checked::after { + left: 16px; +} + +.wf-time-fields { + display: flex; + gap: 10px; + margin-top: 10px; + padding-left: 20px; + border-left: 2px solid var(--border-subtle, #eee); +} + +.wf-time-fields .wf-time-field { + flex: 1; + min-width: 0; +} + +.wf-time-fields .wf-time-field .wf-field-label { + font-size: 11px; + margin-bottom: 4px; +} + +.wf-time-fields .wf-input { + width: 100%; + font-size: 12px; + padding: 6px 10px; +} + +.wf-textarea { + width: 100%; + min-height: 80px; + padding: 9px 12px; + border: 1px solid var(--border-subtle, #ddd); + border-radius: 8px; + font-size: 13px; + font-family: inherit; + color: var(--text-primary, #1a1a1a); + background: var(--bg-inset, #fafafa); + resize: vertical; + line-height: 1.5; + transition: + border-color 0.15s, + box-shadow 0.15s, + background 0.15s; + box-sizing: border-box; +} + +.wf-textarea:focus { + outline: none; + border-color: #3b82f6; + background: var(--bg-surface, #fff); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); +} + +.wf-textarea::placeholder { + color: var(--text-muted, #aaa); +} + +.wf-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-subtle, #ddd); + border-radius: 8px; + font-size: 13px; + font-family: inherit; + color: var(--text-primary, #1a1a1a); + background: var(--bg-inset, #fafafa); + transition: + border-color 0.15s, + box-shadow 0.15s, + background 0.15s; + box-sizing: border-box; +} + +.wf-input:focus { + outline: none; + border-color: #3b82f6; + background: var(--bg-surface, #fff); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); +} + +.wf-field-error .wf-textarea, +.wf-field-error .wf-input { + border-color: #dc2626; +} + +.wf-field-error-text { + font-size: 11px; + color: #dc2626; + margin-top: 4px; +} + +/* ── Inline Error ── */ +.wf-inline-error { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(220, 38, 38, 0.06); + border: 1px solid rgba(220, 38, 38, 0.15); + font-size: 12px; + color: #dc2626; + margin-bottom: 14px; +} + +/* ── Buttons ── */ +.wf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 550; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.wf-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.wf-btn-secondary { + background: var(--bg-inset, #fafafa); + border-color: var(--border-subtle, #ddd); + color: var(--text-secondary, #666); +} + +.wf-btn-secondary:hover:not(:disabled) { + background: var(--card-hover-bg, #f0f0f0); + border-color: var(--border-default, #ccc); + color: var(--text-primary, #1a1a1a); +} + +.wf-btn-primary { + background: #3b82f6; + color: #ffffff; + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.25); +} + +.wf-btn-primary:hover:not(:disabled) { + background: #2563eb; + box-shadow: 0 2px 6px rgba(59, 130, 246, 0.35); + transform: translateY(-0.5px); +} + +.wf-btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.wf-btn-destructive { + background: #dc2626; + color: #ffffff; + box-shadow: 0 1px 3px rgba(220, 38, 38, 0.25); +} + +.wf-btn-destructive:hover:not(:disabled) { + background: #b91c1c; + box-shadow: 0 2px 6px rgba(220, 38, 38, 0.35); + transform: translateY(-0.5px); +} + +.wf-btn-destructive:active:not(:disabled) { + transform: translateY(0); +} + +.wf-btn-success { + background: #16a34a; + color: #ffffff; + box-shadow: 0 1px 3px rgba(22, 163, 74, 0.25); +} + +.wf-btn-success:hover:not(:disabled) { + background: #15803d; + box-shadow: 0 2px 6px rgba(22, 163, 74, 0.35); + transform: translateY(-0.5px); +} + +.wf-btn-success:active:not(:disabled) { + transform: translateY(0); +} + +.wf-btn-danger { + background: #dc2626; + color: #ffffff; + box-shadow: 0 1px 3px rgba(220, 38, 38, 0.25); +} + +.wf-btn-danger:hover:not(:disabled) { + background: #b91c1c; + box-shadow: 0 2px 6px rgba(220, 38, 38, 0.35); + transform: translateY(-0.5px); +} + +.wf-btn-danger:active:not(:disabled) { + transform: translateY(0); +} + +/* ── Confirmation Box ── */ +.wf-confirmation-box { + padding: 14px; + border-radius: 8px; + background: rgba(245, 158, 11, 0.06); + border: 1px solid rgba(245, 158, 11, 0.2); + margin-top: 14px; +} + +.wf-confirmation-box p { + font-size: 13px; + color: var(--text-secondary); + margin: 0 0 12px; + line-height: 1.5; +} + +.wf-confirmation-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* ── Spinner ── */ +.wf-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: wfSpin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes wfSpin { + to { + transform: rotate(360deg); + } +} + +/* ── Checkbox Group (SendQuoteModal) ── */ +.wf-checkbox-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.wf-checkbox-label { + display: flex; + align-items: flex-start; + gap: 10px; + cursor: pointer; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); + transition: + background 0.15s, + border-color 0.15s; +} + +.wf-checkbox-label:hover { + background: var(--card-hover-bg, #f5f5f5); + border-color: var(--border-default, #ccc); +} + +.wf-checkbox-label.wf-checkbox-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.wf-checkbox-label.wf-checkbox-disabled:hover { + background: var(--bg-inset, #fafafa); + border-color: var(--border-subtle, #e5e5e5); +} + +.wf-checkbox-label input[type="checkbox"] { + margin-top: 2px; + accent-color: #3b82f6; + flex-shrink: 0; +} + +.wf-checkbox-text { + flex: 1; +} + +.wf-checkbox-desc { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + display: block; +} + +.wf-checkbox-note { + font-size: 11px; + color: var(--text-muted); + display: block; + margin-top: 2px; +} + +.wf-checkbox-warning { + font-size: 11px; + color: #d97706; + display: block; + margin-top: 2px; +} + +/* ── Send Quote Outcome Section ── */ +.sq-outcome-section { + margin-top: 12px; + border-top: 1px solid var(--border-subtle, #e5e5e5); + padding-top: 12px; +} + +.sq-confirmed-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + box-shadow 0.15s; + font-size: 14px; + color: var(--text-primary); + text-align: left; +} + +.sq-confirmed-toggle:hover { + background: var(--card-hover-bg, #f5f5f5); + border-color: var(--border-default, #ccc); +} + +.sq-confirmed-toggle.sq-active { + background: #ecfdf5; + border-color: #10b981; + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15); +} + +[data-theme="dark"] .sq-confirmed-toggle.sq-active { + background: rgba(16, 185, 129, 0.1); + border-color: #059669; + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12); +} + +.sq-toggle-check { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex-shrink: 0; + border-radius: 6px; + background: var(--bg-inset, #f3f3f3); + border: 1px solid var(--border-subtle, #ddd); + transition: + background 0.15s, + border-color 0.15s; +} + +.sq-active .sq-toggle-check { + background: #10b981; + border-color: #10b981; + color: #fff; +} + +[data-theme="dark"] .sq-active .sq-toggle-check { + background: #059669; + border-color: #059669; +} + +.sq-toggle-label { + font-weight: 600; + font-size: 13px; + flex-shrink: 0; +} + +.sq-toggle-hint { + font-size: 12px; + color: var(--text-muted, #888); + margin-left: auto; +} + +.sq-sub-options { + display: flex; + gap: 8px; + margin-top: 10px; + padding-left: 0; +} + +.sq-sub-option { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + box-shadow 0.15s, + transform 0.1s; + color: var(--text-secondary, #666); + font-size: 13px; +} + +.sq-sub-option:hover { + background: var(--card-hover-bg, #f0f0f0); + border-color: var(--border-default, #ccc); + transform: translateY(-1px); +} + +.sq-sub-option:active { + transform: translateY(0); +} + +.sq-sub-option.sq-sub-active { + background: #eff6ff; + border-color: #3b82f6; + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2); + color: var(--text-primary); +} + +[data-theme="dark"] .sq-sub-option.sq-sub-active { + background: rgba(59, 130, 246, 0.1); + border-color: #2563eb; + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.15); +} + +.sq-sub-option svg { + opacity: 0.5; + transition: opacity 0.15s; +} + +.sq-sub-option.sq-sub-active svg { + opacity: 1; + color: #3b82f6; +} + +[data-theme="dark"] .sq-sub-option.sq-sub-active svg { + color: #60a5fa; +} + +.sq-sub-label { + font-weight: 600; + font-size: 13px; +} + +.sq-sub-desc { + font-size: 11px; + color: var(--text-muted, #888); + text-align: center; + line-height: 1.3; +} + +/* Finalize immediately checkbox row */ +.sq-finalize-row { + margin-top: 8px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); +} + +.sq-finalize-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; +} + +.sq-finalize-check input[type="checkbox"] { + accent-color: #3b82f6; + flex-shrink: 0; +} + +.sq-finalize-hint { + font-size: 11px; + font-weight: 400; + color: var(--text-muted, #888); + margin-left: auto; +} + +/* ── Decision Buttons (ReviewDecisionModal) ── */ +.wf-decision-group { + margin-bottom: 16px; +} + +.wf-decision-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.wf-decision-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 14px 10px; + border-radius: 10px; + border: 2px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + transform 0.15s, + box-shadow 0.15s; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.wf-decision-btn:hover:not(:disabled) { + transform: translateY(-1px); + background: var(--card-hover-bg, #f5f5f5); + border-color: var(--card-hover-border, #ccc); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.wf-decision-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.wf-decision-btn.wf-selected { + border-color: currentColor; + box-shadow: + 0 0 0 1px currentColor, + 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.wf-decision-btn.wf-disabled-perm { + opacity: 0.4; +} + +/* Decision button variants */ +.wf-decision-btn.wf-decision-approve { + color: #16a34a; +} +.wf-decision-btn.wf-decision-approve.wf-selected { + background: rgba(34, 197, 94, 0.06); +} +.wf-decision-btn.wf-decision-reject { + color: #d97706; +} +.wf-decision-btn.wf-decision-reject.wf-selected { + background: rgba(245, 158, 11, 0.06); +} +.wf-decision-btn.wf-decision-send { + color: #2563eb; +} +.wf-decision-btn.wf-decision-send.wf-selected { + background: rgba(59, 130, 246, 0.06); +} +.wf-decision-btn.wf-decision-cancel { + color: #dc2626; +} +.wf-decision-btn.wf-decision-cancel.wf-selected { + background: rgba(220, 38, 38, 0.06); +} + +.wf-decision-desc { + font-size: 11px; + font-weight: 400; + color: var(--text-muted); + text-align: center; +} + +/* ── Finalize Selector (FinalizeModal) ── */ +.wf-finalize-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 16px; + transition: grid-template-columns 0.2s ease; +} + +.wf-finalize-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 18px 12px; + border-radius: 10px; + border: 2px solid var(--border-subtle, #e5e5e5); + background: var(--bg-inset, #fafafa); + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s, + transform 0.2s, + box-shadow 0.2s, + padding 0.2s, + font-size 0.2s, + opacity 0.2s; + font-size: 14px; + font-weight: 600; +} + +/* Shrink the unselected option when a selection is made */ +.wf-finalize-selector.has-selection { + grid-template-columns: 2fr 1fr; +} + +.wf-finalize-selector.has-selection.lost-selected { + grid-template-columns: 1fr 2fr; +} + +.wf-finalize-selector.has-selection .wf-finalize-option:not(.wf-selected) { + padding: 12px 8px; + opacity: 0.5; + border-color: var(--border-subtle, #e5e5e5); + box-shadow: none; +} + +.wf-finalize-selector.has-selection .wf-finalize-option:not(.wf-selected) svg { + width: 16px; + height: 16px; +} + +.wf-finalize-selector.has-selection + .wf-finalize-option:not(.wf-selected):hover { + opacity: 0.75; +} + +.wf-finalize-option:hover:not(:disabled) { + transform: translateY(-1px); + background: var(--card-hover-bg, #f5f5f5); + border-color: var(--card-hover-border, #ccc); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.wf-finalize-option:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.wf-finalize-option.wf-selected { + border-color: currentColor; + box-shadow: + 0 0 0 1px currentColor, + 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.wf-finalize-option.wf-finalize-won { + color: #2563eb; +} + +.wf-finalize-option.wf-finalize-won.wf-selected { + background: rgba(37, 99, 235, 0.06); +} + +.wf-finalize-option.wf-finalize-lost { + color: #dc2626; +} + +.wf-finalize-option.wf-finalize-lost.wf-selected { + background: rgba(220, 38, 38, 0.06); +} + +/* Finalize immediately checkbox */ +.wf-finalize-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + cursor: pointer; + margin-bottom: 12px; + user-select: none; +} + +.wf-finalize-check input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 30px; + height: 16px; + background: #ccc; + border-radius: 999px; + position: relative; + cursor: pointer; + margin: 0; + flex-shrink: 0; + transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + border: none; + box-shadow: none; +} + +.wf-finalize-check input[type="checkbox"]::after { + content: ""; + position: absolute; + top: 50%; + left: 2px; + transform: translateY(-50%); + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wf-finalize-check input[type="checkbox"]:checked { + background: #2563eb; +} + +.wf-finalize-check input[type="checkbox"]:checked::after { + left: 16px; +} + +.wf-finalize-check input[type="checkbox"]:focus, +.wf-finalize-check input[type="checkbox"]:focus-visible { + outline: none; + border: none; + box-shadow: none; +} + +.wf-finalize-check .wf-info-icon { + display: inline-flex; + align-items: center; + color: var(--text-muted, #9ca3af); + cursor: help; + position: relative; +} + +.wf-finalize-check .wf-info-icon:hover { + color: var(--text-secondary, #6b7280); +} + +/* Pending disclaimer notice */ +.wf-pending-notice { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(245, 158, 11, 0.07); + border: 1px solid rgba(245, 158, 11, 0.18); + font-size: 12px; + line-height: 1.5; + color: #92400e; + margin-bottom: 12px; +} + +.wf-pending-notice svg { + flex-shrink: 0; + margin-top: 1px; + color: #d97706; +} + +.wf-pending-notice strong { + font-weight: 600; +} + +/* ══════════════════════════════════════════════════════════════════ + ACTIVITY TIMELINE + ══════════════════════════════════════════════════════════════════ */ + +.activity-tab { + padding: 0; +} + +.at-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.at-header .overview-section-title { + margin: 0; +} + +.at-refresh-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px; + transition: + color 0.15s, + background 0.15s; +} + +.at-refresh-btn:hover { + color: var(--text-primary); + background: var(--nav-hover-bg); +} + +.at-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.at-spinning { + animation: wfSpin 0.8s linear infinite; +} + +/* ── Loading / Empty / Error states ── */ +.at-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 24px 0; + color: var(--text-muted); + font-size: 13px; +} + +.at-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 0; + color: var(--text-muted); +} + +.at-empty p { + margin: 0; + font-size: 13px; +} + +.at-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-radius: 6px; + background: rgba(220, 38, 38, 0.06); + border: 1px solid rgba(220, 38, 38, 0.15); + font-size: 12px; + color: #dc2626; +} + +/* ── Timeline Layout ── */ +.at-timeline { + display: flex; + flex-direction: column; +} + +.at-entry { + display: flex; + gap: 12px; + min-height: 56px; +} + +.at-entry.at-entry-open { + /* Subtle highlight for open/active activities */ +} + +/* ── Connector (dots + lines) ── */ +.at-connector { + display: flex; + flex-direction: column; + align-items: center; + width: 24px; + flex-shrink: 0; + padding-top: 2px; +} + +.at-dot { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.12); + color: #3b82f6; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.at-dot.at-dot-open { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); +} + +.at-dot.at-dot-system { + background: rgba(107, 114, 128, 0.1); + color: #6b7280; +} + +.at-line { + width: 2px; + flex: 1; + background: var(--card-border); + min-height: 16px; +} + +/* ── Entry Content ── */ +.at-content { + flex: 1; + padding-bottom: 16px; + min-width: 0; +} + +.at-content-header { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.at-type-badge { + display: inline-flex; + padding: 1px 7px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* Optima type badge variants */ +.at-type-badge.at-type-created { + background: rgba(234, 179, 8, 0.1); + color: #ca8a04; +} +.at-type-badge.at-type-setup { + background: rgba(107, 114, 128, 0.1); + color: #6b7280; +} +.at-type-badge.at-type-review { + background: rgba(249, 115, 22, 0.1); + color: #ea580c; +} +.at-type-badge.at-type-sent { + background: rgba(59, 130, 246, 0.1); + color: #2563eb; +} +.at-type-badge.at-type-confirmed { + background: rgba(16, 185, 129, 0.1); + color: #059669; +} +.at-type-badge.at-type-sent-confirmed { + background: rgba(6, 182, 212, 0.1); + color: #0891b2; +} +.at-type-badge.at-type-revision { + background: rgba(245, 158, 11, 0.1); + color: #d97706; +} +.at-type-badge.at-type-finalized { + background: rgba(139, 92, 246, 0.1); + color: #7c3aed; +} +.at-type-badge.at-type-converted { + background: rgba(236, 72, 153, 0.1); + color: #db2777; +} +.at-type-badge.at-type-generated { + background: rgba(20, 184, 166, 0.1); + color: #0d9488; +} +.at-type-badge.at-type-default { + background: rgba(107, 114, 128, 0.08); + color: var(--text-muted); +} + +/* ── Quote Reference Sub-Item ── */ +.at-quote-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 4px 10px; + border: 1px solid rgba(20, 184, 166, 0.25); + border-radius: 6px; + background: rgba(20, 184, 166, 0.06); + color: #0d9488; + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} +.at-quote-link:hover { + background: rgba(20, 184, 166, 0.14); + border-color: rgba(20, 184, 166, 0.4); +} +.at-quote-link svg { + flex-shrink: 0; + opacity: 0.7; +} +.at-quote-link-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} +.at-quote-link-arrow { + opacity: 0.4; + transition: + opacity 0.15s ease, + transform 0.15s ease; +} +.at-quote-link:hover .at-quote-link-arrow { + opacity: 0.8; + transform: translateX(2px); +} + +[data-theme="dark"] .at-quote-link { + border-color: rgba(20, 184, 166, 0.2); + background: rgba(20, 184, 166, 0.08); + color: #5eead4; +} +[data-theme="dark"] .at-quote-link:hover { + background: rgba(20, 184, 166, 0.16); + border-color: rgba(20, 184, 166, 0.35); +} + +/* ── Status Transition Pill ── */ +.at-transition-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + background: rgba(59, 130, 246, 0.06); + color: var(--text-secondary); +} + +.at-transition-from { + opacity: 0.6; +} + +.at-transition-to { + color: var(--text-primary); +} + +/* ── Open Badge ── */ +.at-open-badge { + display: inline-flex; + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + background: rgba(34, 197, 94, 0.12); + color: #16a34a; +} + +/* ── Activity Name / Notes ── */ +.at-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 2px; + line-height: 1.4; +} + +.at-notes { + font-size: 12px; + color: var(--text-secondary); + margin: 0 0 6px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Meta Row ── */ +.at-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.at-user { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); +} + +.at-user.at-system { + color: var(--text-muted); + font-style: italic; +} + +.at-timestamp { + font-size: 11px; + color: var(--text-muted); +} + +.at-closed-at { + opacity: 0.7; +} diff --git a/src/styles/sales/sales.css b/src/styles/sales/sales.css index 99bc2a2..05c67c7 100644 --- a/src/styles/sales/sales.css +++ b/src/styles/sales/sales.css @@ -416,6 +416,14 @@ color: #6b7280; } +/* ── Canceled: muted gray with strikethrough feel ── */ +.sales-status-badge.status-canceled { + background: rgba(107, 114, 128, 0.12); + color: #6b7280; + text-decoration: line-through; + opacity: 0.8; +} + .sales-status-badge.status-equiv { border: 1px dashed currentColor; cursor: default;