fix: remove nested .git folders, re-add as normal directories

This commit is contained in:
2026-03-22 17:50:47 -05:00
parent f55c7e47c9
commit 6b7eec67b8
1870 changed files with 4170168 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
<slot />
@@ -0,0 +1,24 @@
import { Actions, redirect } from "@sveltejs/kit";
import { optima } from "$lib";
export const actions: Actions = {
login: async (event) => {
const data = await event.request.formData();
const tokens = await optima.user.awaitAuthCallback(
data.get("callbackKey") as string,
);
event.cookies.set("accessToken", tokens.accessToken, {
httpOnly: true,
path: "/",
});
event.cookies.set("refreshToken", tokens.refreshToken, {
httpOnly: true,
path: "/",
});
// Redirect to home page after successful login
redirect(303, "/");
},
} satisfies Actions;
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { optima } from "$lib";
import { PUBLIC_API_URL } from "$env/static/public";
import { enhance } from "$app/forms";
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
import { writable } from "svelte/store";
import { theme } from "$lib/theme";
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
let loading = writable(false);
/** Track whether the server-side form action has finished */
let formActionDone = false;
function handleSubmit(e: SubmitEvent) {
if ($loading) return;
$loading = true;
formActionDone = false;
const url = uriData.uri;
const width = 420;
const height = 430;
const left = Math.round((window.screen.width - width) / 2);
const top = Math.round((window.screen.height - height) / 2);
const features = `width=${width},height=${height},left=${left},top=${top},resizable=no,menubar=no,toolbar=no,location=no,status=no,scrollbars=yes`;
const popup = window.open(url, "msAuth", features);
if (!popup) {
// Popup was blocked — reset so the user can try again
$loading = false;
return;
}
// Only hide the spinner when BOTH the popup has closed AND the
// server-side form action (awaitAuthCallback) has completed.
// Previously the spinner was removed as soon as the popup closed,
// leaving a gap while the server was still processing the callback.
const popupCheckInterval = setInterval(() => {
if (popup.closed) {
clearInterval(popupCheckInterval);
if (formActionDone) {
$loading = false;
}
// If the form action isn't done yet the spinner stays;
// the enhance callback below will clear it.
}
}, 500);
}
</script>
<div class="container">
<button
class="theme-toggle login-theme-toggle"
onclick={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<form
action="?/login"
method="POST"
onsubmit={handleSubmit}
use:enhance={() => {
// Called when the form action response comes back from the server
return async ({ update }) => {
formActionDone = true;
$loading = false;
await update();
};
}}
>
<input type="hidden" name="callbackKey" value={uriData.callbackKey} />
<button
type="submit"
class="ms-button"
aria-label="Sign in with Microsoft"
disabled={$loading}
aria-busy={$loading}
>
<span class="ms-logo" aria-hidden="true"></span>
Sign in with Microsoft
</button>
</form>
</div>
<LoadingSpinner loading={$loading} />
<style>
:global(html, body, #svelte) {
height: 100%;
margin: 0;
}
.container {
min-height: 100vh;
min-width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-base);
transition: background 0.2s ease;
}
.ms-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border-default);
border-radius: 6px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
.ms-button:hover:not([disabled]) {
background: var(--card-hover-bg);
border-color: var(--border-strong);
}
.ms-button[disabled] {
opacity: 0.6;
cursor: default;
}
.login-theme-toggle {
position: absolute;
top: 16px;
right: 16px;
}
.ms-logo {
width: 20px;
height: 20px;
background: linear-gradient(
90deg,
#f25022 0 25%,
#7fb900 25% 50%,
#00a4ef 50% 75%,
#ffb900 75% 100%
);
border-radius: 2px;
flex-shrink: 0;
}
</style>
@@ -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");
});
});
});
+86
View File
@@ -0,0 +1,86 @@
<script>
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import "../styles/errorpage.css";
$: status = $page.status || 500;
$: message = $page.error?.message || "Something went wrong";
function goBack() {
history.back();
}
</script>
<svelte:head>
<title>Error {status} — Project Optima</title>
</svelte:head>
<div class="error-page">
<div class="error-pane">
<!-- Pane header -->
<div class="error-pane-header">
<h2 class="error-pane-title">Error</h2>
<span class="error-status-badge">{status}</span>
</div>
<!-- Pane body -->
<div class="error-pane-body">
<div class="error-illustration">
<svg viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
<circle
cx="60"
cy="60"
r="56"
fill="var(--error-circle-outer)"
stroke="var(--error-circle-stroke)"
stroke-width="2"
/>
<circle cx="60" cy="60" r="40" fill="var(--error-circle-inner)" />
<path
d="M60 35v30"
stroke="#dc2626"
stroke-width="5"
stroke-linecap="round"
/>
<circle cx="60" cy="78" r="4" fill="#dc2626" />
</svg>
</div>
<h3 class="error-heading">Oops! Something went wrong</h3>
<p class="error-message">{message}</p>
<p class="error-hint">
Please try again or contact support if the problem persists.
</p>
<div class="error-actions">
<button class="btn btn-primary" on:click={goBack}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Go Back
</button>
<a href="/" class="btn btn-secondary">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
Go Home
</a>
</div>
</div>
</div>
</div>
+29
View File
@@ -0,0 +1,29 @@
import { optima } from "$lib";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken ?? null;
// Only check permissions if the user is authenticated
if (!accessToken) {
return { canViewAdmin: false, user: null };
}
let canViewAdmin = false;
let user = null;
try {
const userInfo = await optima.user.fetchInfo(accessToken);
user = userInfo.data ?? null;
const permResult = await optima.user.checkPermissions(accessToken, [
"ui.navigation.admin.view",
]);
canViewAdmin = permResult?.data?.results?.[0]?.hasPermission === true;
} catch (err) {
console.error("Admin permission check or user fetch failed:", err);
canViewAdmin = false;
user = null;
}
return { canViewAdmin, user };
};
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { optima } from "$lib";
import { page } from "$app/stores";
import { navigating } from "$app/stores";
import { theme } from "$lib/theme";
import LoadingSpinner from "../components/LoadingSpinner.svelte";
import SessionGuard from "../components/SessionGuard.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte";
const navItems = [
{
href: "/",
label: "Home",
icon: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>',
exact: true,
},
{
href: "/companies",
label: "Companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/procurement",
label: "Procurement",
icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line>',
},
{
href: "/sales",
label: "Sales",
icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>',
},
];
const adminNavItem = {
href: "/admin",
label: "Admin",
icon: '<path d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2zm10-10V7a4 4 0 0 0-8 0v4h8z"></path>',
};
$: canViewAdmin = $page.data?.canViewAdmin === true;
function isActive(pathname: string, item: (typeof navItems)[0]) {
return item.exact ? pathname === item.href : pathname.startsWith(item.href);
}
</script>
{#if $page.route.id?.startsWith("/(auth)")}
<slot />
{:else}
<SessionGuard />
<LoadingSpinner loading={!!$navigating} />
<div class="layout-container">
<header class="header">
<div class="header-content">
<h1>Project Optima</h1>
<button
class="theme-toggle"
on:click={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</div>
</header>
<div class="layout-wrapper">
<aside class="sidebar">
<nav class="sidebar-nav">
{#each navItems as item}
<a
href={item.href}
class="nav-item {isActive($page.url.pathname, item)
? 'active'
: ''}"
title={item.label}
>
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@html item.icon}
</svg>
<span class="nav-label">{item.label}</span>
</a>
{/each}
{#if canViewAdmin}
<hr class="nav-divider" />
<a
href={adminNavItem.href}
class="nav-item {$page.url.pathname.startsWith('/admin')
? 'active'
: ''}"
title={adminNavItem.label}
>
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@html adminNavItem.icon}
</svg>
<span class="nav-label">{adminNavItem.label}</span>
</a>
{/if}
</nav>
</aside>
<div class="content-area">
<div class="accent-bar"></div>
<main class="main-content">
<Breadcrumb />
<slot />
</main>
</div>
</div>
<footer class="footer">
<small>&copy; {new Date().getFullYear()} Total Tech Solutions, LLC</small>
</footer>
</div>
{/if}
+10
View File
@@ -0,0 +1,10 @@
import type { LayoutLoad } from "./$types";
import "../styles/app.css";
import "../styles/layout.css";
import "../styles/errorpage.css";
export const load: LayoutLoad = async ({ data }) => {
return {
...data,
};
};
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
// You can add any JavaScript logic here if needed
</script>
<svelte:head>
<title>Home — App</title>
</svelte:head>
<main>
<h1>Home Page</h1>
</main>
+47
View File
@@ -0,0 +1,47 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals, parent }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Grab the root layout data to reuse the admin-view permission it already checked
const parentData = await parent();
// If the root layout already determined we can't view admin, redirect immediately
if (parentData?.canViewAdmin === false) {
throw redirect(303, "/");
}
// Fetch sub-tab permissions and user info in parallel
const [permissions, userInfo] = await Promise.all([
checkPermissions(accessToken, [
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
"ui.navigation.reports.view",
]),
optima.user.fetchInfo(accessToken),
]);
return {
user: userInfo?.data ?? null,
permissions: {
"ui.navigation.admin.view": true, // Already verified via parent
...permissions,
},
};
} catch (err) {
// Re-throw redirects so SvelteKit handles them
if (err && typeof err === "object" && "status" in err) {
throw err;
}
handleApiError(err);
}
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/admin.css";
import type { PermissionMap } from "$lib/permissions";
export let data: {
user: {
id: string;
name?: string;
email?: string;
[key: string]: unknown;
} | null;
permissions: PermissionMap;
};
$: permissions = data.permissions;
$: userName = data.user?.name || "Admin";
// Tab definitions — each gated by a permission
const allTabs = [
{ label: "Overview", href: "/admin", exact: true, permission: null },
{
label: "Users",
href: "/admin/users",
exact: false,
permission: "admin.users.view",
},
{
label: "Roles",
href: "/admin/roles",
exact: false,
permission: "admin.roles.view",
},
{
label: "Credential Types",
href: "/admin/credential-types",
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
$: visibleTabs = allTabs.filter(
(t) => t.permission === null || permissions[t.permission] === true,
);
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Admin — Project Optima</title>
</svelte:head>
<div class="admin-page">
<div class="admin-pane">
<!-- Pane header -->
<div class="admin-header">
<div class="admin-header-left">
<svg
class="admin-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<h2 class="admin-title">Administration</h2>
<span class="admin-subtitle">Welcome back, {userName}</span>
</div>
</div>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
{#each visibleTabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</div>
<!-- Tab content -->
<div class="admin-body">
<slot />
</div>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { companyCount: null };
}
try {
const companyCount = await optima.company.count(accessToken);
return {
companyCount: companyCount ?? null,
};
} catch (err) {
handleApiError(err);
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { goto } from "$app/navigation";
export let data: {
companyCount: number | null;
};
$: companyCount = data.companyCount;
const quickActions = [
{
href: "/companies",
name: "Companies",
desc: "View and manage companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/admin/users",
name: "Manage Users",
desc: "View, edit, and assign user roles",
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
},
{
href: "/admin/roles",
name: "Manage Roles",
desc: "Configure roles and permissions",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
},
{
href: "/admin/credential-types",
name: "Credential Types",
desc: "Configure credential type definitions",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
},
];
</script>
<!-- Stats overview -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M3 21h18" />
<path d="M5 21V7l8-4v18" />
<path d="M19 21V11l-6-4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{companyCount ?? "—"}</span>
<span class="stat-label">Companies</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Users</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Credentials</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Activity Today</span>
</div>
</div>
</div>
<!-- Quick actions -->
<h3 class="section-heading">Quick Actions</h3>
<div class="actions-grid">
{#each quickActions as action}
<a
href={action.href}
class="action-card"
on:click|preventDefault={() => goto(action.href)}
>
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
{@html action.icon}
</svg>
</div>
<div class="action-text">
<span class="action-name">{action.name}</span>
<span class="action-desc">{action.desc}</span>
</div>
<svg
class="action-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
<!-- Recent activity placeholder -->
<h3 class="section-heading">Recent Activity</h3>
<div class="activity-section">
<div class="activity-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Activity feed coming soon</span>
</div>
</div>
@@ -0,0 +1,179 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { credentialTypes: [], permissions: {}, valueTypes: [] };
}
try {
const [typesResult, permissions, valueTypesResult] = await Promise.all([
optima.credentialType.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.credential-types.view",
"admin.credential-types.create",
"admin.credential-types.edit",
"admin.credential-types.delete",
]),
optima.credential.fetchValueTypes(accessToken).catch((err) => {
console.error(
"Failed to fetch value types:",
err?.response?.data ?? err?.message ?? err,
);
return { data: [] };
}),
]);
const credentialTypes = typesResult?.data ?? [];
const valueTypes: string[] = valueTypesResult?.data ?? [];
return {
credentialTypes,
permissions,
valueTypes,
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!name || !permissionScope) {
return fail(400, { message: "Name and permission scope are required." });
}
let fields: Array<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField
> = [];
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.create(accessToken, {
name,
permissionScope,
icon,
fields,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to create credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const permissionScope = (formData.get("permissionScope") as string)?.trim();
const icon = (formData.get("icon") as string)?.trim() || undefined;
const fieldsJson = (formData.get("fields") as string)?.trim();
if (!id || !name || !permissionScope) {
return fail(400, { message: "Required fields are missing." });
}
let fields:
| Array<
Omit<
import("$lib/optima-api/modules/credentialTypes").CredentialTypeField,
"id"
>
>
| undefined;
if (fieldsJson) {
try {
fields = JSON.parse(fieldsJson);
} catch {
return fail(400, { message: "Invalid fields data." });
}
}
try {
await optima.credentialType.update(accessToken, id, {
name,
permissionScope,
icon,
fields: fields as any,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to update credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteCredentialType: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Credential type ID is required." });
}
try {
await optima.credentialType.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error
? err.message
: "Failed to delete credential type.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
@@ -0,0 +1,682 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type {
CredentialType,
CredentialTypeField,
} from "$lib/optima-api/modules/credentialTypes";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateCredentialTypeModal from "../../../components/CreateCredentialTypeModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/credential-types.css";
export let data: {
permissions: PermissionMap;
credentialTypes: CredentialType[];
valueTypes: string[];
};
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
$: canCreate = data.permissions["admin.credential-types.create"] === true;
$: canEdit = data.permissions["admin.credential-types.edit"] === true;
$: canDelete = data.permissions["admin.credential-types.delete"] === true;
$: credentialTypes = data.credentialTypes;
$: valueTypes = data.valueTypes ?? [];
// Search / filter
let searchQuery = "";
$: filteredTypes = credentialTypes.filter((ct) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
ct.name.toLowerCase().includes(q) ||
ct.permissionScope.toLowerCase().includes(q)
);
});
// Create / edit modal state
let isCreateModalOpen = false;
let typeToEdit: CredentialType | null = null;
function openEdit(ct: CredentialType) {
typeToEdit = ct;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Delete confirmation
let typeToDelete: CredentialType | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(ct: CredentialType) {
typeToDelete = ct;
openMenuId = null;
}
function cancelDelete() {
typeToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
typeToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete credential type.";
}
await update();
};
};
// Expanded row state
let expandedTypeId: string | null = null;
function toggleType(id: string) {
expandedTypeId = expandedTypeId === id ? null : id;
}
function valueTypeLabel(vt: string): string {
return vt
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage credential types. Contact your administrator to request access."
/>
{:else if credentialTypes.length === 0}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<h3>No Credential Types Found</h3>
<p>
There are no credential types configured yet. Create your first credential
type to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-ct-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential Type
</button>
{/if}
</div>
{:else}
<CreateCredentialTypeModal
isOpen={isCreateModalOpen}
{typeToEdit}
{valueTypes}
onClose={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
typeToEdit = null;
}}
/>
<DeleteConfirmDialog
isOpen={!!typeToDelete}
title="Delete Credential Type"
idValue={typeToDelete?.id ?? ""}
formAction="?/deleteCredentialType"
confirmLabel="Delete"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{typeToDelete?.name}</strong>?
{#if typeToDelete && typeToDelete.credentialCount > 0}
This type has <strong>{typeToDelete.credentialCount}</strong>
credential{typeToDelete.credentialCount === 1 ? "" : "s"} associated with it.
{/if}
This action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Credential Types
<span class="result-count"
>{filteredTypes.length} type{filteredTypes.length === 1
? ""
: "s"}{#if searchQuery.trim()}&nbsp;(filtered){/if}</span
>
</h3>
<div style="display:flex;align-items:center;gap:10px;">
<div class="ct-search-wrap">
<svg
class="ct-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="ct-search-input"
placeholder="Search types…"
bind:value={searchQuery}
/>
</div>
{#if canCreate}
<button
type="button"
class="create-ct-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Type
</button>
{/if}
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Permission Scope</th>
<th>Fields</th>
<th>Credentials</th>
<th>Created</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredTypes as ct (ct.id)}
<tr
class="ct-row"
class:expanded={expandedTypeId === ct.id}
on:click={() => toggleType(ct.id)}
>
<td>
<div class="ct-name-cell">
<svg
class="ct-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span class="ct-name">{ct.name}</span>
</div>
</td>
<td>
<span class="ct-scope">{ct.permissionScope}</span>
</td>
<td>
<span class="ct-field-count">
{ct.fields.length} field{ct.fields.length === 1 ? "" : "s"}
</span>
</td>
<td>
<span class="ct-cred-count">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"
/>
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
{ct.credentialCount} credential{ct.credentialCount === 1
? ""
: "s"}
</span>
</td>
<td>{formatDate(ct.createdAt)}</td>
<td>{formatDate(ct.updatedAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="Credential type actions"
on:click|stopPropagation={() => toggleMenu(ct.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === ct.id}
<div class="ct-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="ct-menu-item"
on:click|stopPropagation={() => openEdit(ct)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="ct-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="ct-menu-item ct-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(ct)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedTypeId === ct.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedTypeId === ct.id}
<tr class="ct-detail-row">
<td colspan="7">
<div class="ct-detail-content">
<div class="ct-detail-grid">
<!-- Info section -->
<div class="ct-detail-section">
<h4 class="ct-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Details
</h4>
<div class="ct-detail-fields">
<div class="ct-detail-field">
<span class="detail-label">ID</span>
<span class="detail-value detail-mono">{ct.id}</span>
</div>
<div class="ct-detail-field">
<span class="detail-label">Permission Scope</span>
<span class="detail-value detail-mono"
>{ct.permissionScope}</span
>
</div>
{#if ct.icon}
<div class="ct-detail-field">
<span class="detail-label">Icon</span>
<span class="detail-value">{ct.icon}</span>
</div>
{/if}
<div class="ct-detail-field">
<span class="detail-label">Credentials</span>
<span class="detail-value"
>{ct.credentialCount} credential{ct.credentialCount ===
1
? ""
: "s"}</span
>
</div>
<div class="ct-detail-field">
<span class="detail-label">Created</span>
<span class="detail-value"
>{formatDate(ct.createdAt)}</span
>
</div>
<div class="ct-detail-field">
<span class="detail-label">Updated</span>
<span class="detail-value"
>{formatDate(ct.updatedAt)}</span
>
</div>
</div>
</div>
<!-- Fields section -->
<div class="ct-detail-section">
<h4 class="ct-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Fields
<span class="detail-count">{ct.fields.length}</span>
</h4>
{#if ct.fields.length === 0}
<p class="ct-detail-empty">No fields defined</p>
{:else}
<div class="ct-field-list">
{#each ct.fields as field (field.id)}
<div class="ct-field-card">
<div class="ct-field-icon">
{#if field.valueType === "multi_credential"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect
x="2"
y="3"
width="20"
height="6"
rx="1"
/>
<rect
x="2"
y="15"
width="20"
height="6"
rx="1"
/>
<path d="M12 9v6" />
</svg>
{:else if field.secure}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
{/if}
</div>
<div class="ct-field-info">
<span class="ct-field-name">{field.name}</span>
<div class="ct-field-meta">
<span>{valueTypeLabel(field.valueType)}</span>
{#if field.required}
<span class="ct-field-badge required"
>Required</span
>
{/if}
{#if field.secure}
<span class="ct-field-badge secure"
>Secure</span
>
{/if}
</div>
</div>
</div>
{#if field.valueType === "multi_credential" && field.subFields && field.subFields.length > 0}
<div class="ct-subfield-group">
<div class="ct-subfield-connector"></div>
<div class="ct-subfield-list">
{#each field.subFields as subField (subField.id)}
<div class="ct-field-card ct-subfield-card">
<div
class="ct-field-icon ct-subfield-icon"
>
{#if subField.secure}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
/>
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline
points="4 7 4 4 20 4 20 7"
/>
<line
x1="9"
y1="20"
x2="15"
y2="20"
/>
<line
x1="12"
y1="4"
x2="12"
y2="20"
/>
</svg>
{/if}
</div>
<div class="ct-field-info">
<span class="ct-field-name"
>{subField.name}</span
>
<div class="ct-field-meta">
<span
>{valueTypeLabel(
subField.valueType,
)}</span
>
{#if subField.required}
<span
class="ct-field-badge required"
>Required</span
>
{/if}
{#if subField.secure}
<span class="ct-field-badge secure"
>Secure</span
>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if filteredTypes.length === 0 && searchQuery.trim()}
<div class="admin-tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3>No Results</h3>
<p>
No credential types match &ldquo;{searchQuery}&rdquo;. Try a different
search.
</p>
</div>
{/if}
{/if}
@@ -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<string, string>) {
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({});
});
});
});
});
+77
View File
@@ -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,
}),
});
});
});
+50
View File
@@ -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);
});
});
@@ -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,
};
};
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
const reportTabs = [
{
id: "internal-review",
label: "Internal Review",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4",
description:
"View opportunities currently in internal review, with reviewer assignments and time in review.",
},
{
id: "loss",
label: "Loss Report",
icon: "M13 17h8m0 0V9m0 8l-8-8-4 4-6-6",
description:
"Analyze lost opportunities by reason, rep, time period, and revenue impact.",
},
{
id: "cancelled",
label: "Cancelled",
icon: "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636",
description:
"Track cancelled opportunities with cancellation reasons and patterns.",
},
{
id: "won",
label: "Won Report",
icon: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
description:
"Review won opportunities, win rates, revenue captured, and sales cycle metrics.",
},
{
id: "follow-up-lateness",
label: "Follow-Up Lateness",
icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
description:
"Monitor overdue follow-up activities, stale opportunities, and SLA compliance.",
},
] as const;
type ReportTab = (typeof reportTabs)[number]["id"];
let activeReport: ReportTab = "internal-review";
</script>
<div class="reports-page">
<div class="reports-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<h3 class="reports-title">Workflow Reports</h3>
</div>
<!-- Report sub-tabs -->
<div class="reports-subtabs" role="tablist">
{#each reportTabs as tab}
<button
class="reports-subtab"
class:active={activeReport === tab.id}
role="tab"
aria-selected={activeReport === tab.id}
on:click={() => (activeReport = tab.id)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d={tab.icon} />
</svg>
{tab.label}
</button>
{/each}
</div>
<!-- Report content -->
<div class="reports-content">
{#each reportTabs as tab}
{#if activeReport === tab.id}
<div class="report-panel">
<div class="report-placeholder">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="48"
height="48"
class="report-placeholder-icon"
>
<path d={tab.icon} />
</svg>
<h4 class="report-placeholder-title">{tab.label}</h4>
<p class="report-placeholder-desc">{tab.description}</p>
<p class="report-placeholder-note">
Report endpoints are being built. This view will populate
automatically when the API is ready.
</p>
</div>
</div>
{/if}
{/each}
</div>
</div>
<style>
.reports-page {
padding: 0;
}
.reports-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--text-primary);
}
.reports-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.reports-subtabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--nav-hover-bg);
border-radius: 8px;
margin-bottom: 20px;
overflow-x: auto;
}
.reports-subtab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
}
.reports-subtab:hover {
background: var(--nav-active-bg);
color: var(--text-primary);
}
.reports-subtab.active {
background: var(--card-bg);
color: var(--text-primary);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.reports-content {
min-height: 300px;
}
.report-panel {
animation: reportFadeIn 0.2s ease;
}
@keyframes reportFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.report-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.report-placeholder-icon {
color: var(--text-muted);
opacity: 0.5;
margin-bottom: 16px;
}
.report-placeholder-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.report-placeholder-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px;
max-width: 400px;
line-height: 1.5;
}
.report-placeholder-note {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
margin: 0;
padding: 8px 16px;
background: var(--bg-inset);
border-radius: 6px;
}
@media (max-width: 768px) {
.reports-subtabs {
flex-wrap: nowrap;
}
.reports-subtab {
font-size: 11px;
padding: 6px 10px;
}
}
</style>
@@ -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" });
});
});
+148
View File
@@ -0,0 +1,148 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { roles: [], permissions: {}, permissionNodes: {} };
}
try {
const [rolesResult, permissions, permNodesResult] = await Promise.all([
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.roles.view",
"admin.roles.create",
"admin.roles.edit",
"admin.roles.delete",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const roles = rolesResult?.data ?? [];
// Fetch users for each role in parallel
const rolesWithUsers = await Promise.all(
roles.map(async (role: Record<string, unknown>) => {
try {
const usersResult = await optima.role.fetchUsers(
accessToken,
role.id as string,
);
return { ...role, users: usersResult?.data ?? [] };
} catch {
return { ...role, users: [] };
}
}),
);
return {
roles: rolesWithUsers,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!title || !moniker) {
return fail(400, { message: "Title and moniker are required." });
}
try {
await optima.role.create(accessToken, { title, moniker, permissions });
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to create role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!id || !title || !moniker) {
return fail(400, { message: "Required fields are missing." });
}
try {
await optima.role.update(accessToken, id, {
title,
moniker,
permissions,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Role ID is required." });
}
try {
await optima.role.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+463
View File
@@ -0,0 +1,463 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import type { PermissionMap } from "$lib/permissions";
import type { Role } from "$lib/optima-api/modules/roles";
import type { PermissionsCategorized } from "$lib/optima-api/modules/permissions";
import { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import CreateRoleModal from "../../../components/CreateRoleModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/roles.css";
interface RoleUser {
id: string;
name: string;
login: string;
roles: string[];
createdAt: string;
updatedAt: string;
}
type RoleWithUsers = Role & { users: RoleUser[] };
export let data: {
permissions: PermissionMap;
roles: RoleWithUsers[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.roles.view"] === true;
$: canCreate = data.permissions["admin.roles.create"] === true;
$: canEdit = data.permissions["admin.roles.edit"] === true;
$: canDelete = data.permissions["admin.roles.delete"] === true;
$: roles = data.roles;
// Create/edit modal state
let isCreateModalOpen = false;
let roleToEdit: Role | null = null;
function openEdit(r: Role) {
roleToEdit = r;
isCreateModalOpen = true;
openMenuId = null;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Delete confirmation
let roleToDelete: Role | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(r: Role) {
roleToDelete = r;
openMenuId = null;
}
function cancelDelete() {
roleToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
roleToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete role.";
}
await update();
};
};
// Expanded row state
let expandedRoleId: string | null = null;
function toggleRole(id: string) {
expandedRoleId = expandedRoleId === id ? null : id;
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage roles. Contact your administrator to request access."
/>
{:else if roles.length === 0}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<h3>No Roles Found</h3>
<p>
There are no roles configured yet. Create your first role to get started.
</p>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
{:else}
<CreateRoleModal
isOpen={isCreateModalOpen}
permissionNodes={data.permissionNodes}
{roleToEdit}
onClose={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
onSuccess={() => {
isCreateModalOpen = false;
roleToEdit = null;
}}
/>
<DeleteConfirmDialog
isOpen={!!roleToDelete}
title="Delete Role"
idValue={roleToDelete?.id ?? ""}
formAction="?/deleteRole"
confirmLabel="Delete Role"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{roleToDelete?.title}</strong>? This
action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Roles
<span class="result-count"
>{roles.length} role{roles.length === 1 ? "" : "s"}</span
>
</h3>
{#if canCreate}
<button
type="button"
class="create-role-btn"
on:click={() => (isCreateModalOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Role
</button>
{/if}
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Moniker</th>
<th>Permissions</th>
<th>Users</th>
<th>Created</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr
class="role-row"
class:expanded={expandedRoleId === role.id}
on:click={() => toggleRole(role.id)}
>
<td>
<div class="role-title-cell">
<svg
class="role-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="role-title">{role.title}</span>
</div>
</td>
<td>
<span class="role-moniker">{role.moniker}</span>
</td>
<td>
<span class="role-perm-count">
{role.permissions.length} permission{role.permissions.length ===
1
? ""
: "s"}
</span>
</td>
<td>
<span class="role-user-count">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
{role.users.length} user{role.users.length === 1 ? "" : "s"}
</span>
</td>
<td>{formatDate(role.createdAt)}</td>
<td>{formatDate(role.updatedAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if role.moniker === "administrator"}
<span class="system-badge">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
System
</span>
{:else if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="Role actions"
on:click|stopPropagation={() => toggleMenu(role.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === role.id}
<div class="role-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="role-menu-item"
on:click|stopPropagation={() => openEdit(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="role-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="role-menu-item role-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(role)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedRoleId === role.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedRoleId === role.id}
<tr class="role-detail-row">
<td colspan="7">
<div class="role-detail-content">
<div class="role-detail-grid">
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Permissions
<span class="detail-count"
>{role.permissions.length}</span
>
</h4>
{#if role.permissions.length === 0}
<p class="role-detail-empty">No permissions assigned</p>
{:else}
<div class="permission-tags">
{#each role.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
<div class="role-detail-section">
<h4 class="role-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Users
<span class="detail-count">{role.users.length}</span>
</h4>
{#if role.users.length === 0}
<p class="role-detail-empty">
No users assigned to this role
</p>
{:else}
<div class="user-list">
{#each role.users as user (user.id)}
<div class="user-card">
<div class="user-avatar">
{user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="user-info">
<span class="user-name">{user.name}</span>
<span class="user-login">{user.login}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
@@ -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<string, string | string[]>) {
const fd = new Map<string, string | string[]>();
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({});
});
});
});
});
+143
View File
@@ -0,0 +1,143 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { users: [], roles: [], permissions: {} };
}
try {
const [usersResult, rolesResult, permissions, permNodesResult] =
await Promise.all([
optima.users.fetchAll(accessToken),
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.users.view",
"admin.users.edit",
"admin.users.delete",
"user.roles.other",
"user.permissions.other",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const allUsers = usersResult?.data ?? [];
const allRoles = rolesResult?.data ?? [];
// Fetch roles for each user in parallel
const usersWithRoles = await Promise.all(
allUsers.map(async (user) => {
try {
const rolesResult = await optima.users.fetchRoles(
accessToken,
user.id,
);
return { ...user, roleDetails: rolesResult?.data ?? [] };
} catch {
return { ...user, roleDetails: [] };
}
}),
);
return {
users: usersWithRoles,
roles: allRoles,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
updateUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const image = (formData.get("image") as string)?.trim() || undefined;
const rolesJson = (formData.get("roles") as string)?.trim();
const permissionsJson = (formData.get("permissions") as string)?.trim();
if (!id || !name) {
return fail(400, { message: "User ID and name are required." });
}
const updates: {
name: string;
image?: string;
roles?: string[];
permissions?: string[];
} = { name, image };
if (rolesJson) {
try {
updates.roles = JSON.parse(rolesJson);
} catch {
return fail(400, { message: "Invalid roles data." });
}
}
if (permissionsJson) {
try {
updates.permissions = JSON.parse(permissionsJson);
} catch {
return fail(400, { message: "Invalid permissions data." });
}
}
try {
await optima.users.update(accessToken, id, updates);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteUser: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "User ID is required." });
}
try {
await optima.users.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete user.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
+512
View File
@@ -0,0 +1,512 @@
<script lang="ts">
import type { SubmitFunction } from "@sveltejs/kit";
import EmailText from "../../../components/EmailText.svelte";
import type { PermissionMap } from "$lib/permissions";
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 { formatDate } from "$lib/utils";
import { positionMenu } from "$lib/actions";
import EditUserModal from "../../../components/EditUserModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import DeleteConfirmDialog from "../../../components/DeleteConfirmDialog.svelte";
import "../../../styles/admin/users.css";
type UserWithRoles = User & { roleDetails: Role[] };
export let data: {
permissions: PermissionMap;
users: UserWithRoles[];
roles: Role[];
permissionNodes: PermissionsCategorized;
};
$: hasAccess = data.permissions["admin.users.view"] === true;
$: canEdit = data.permissions["admin.users.edit"] === true;
$: canDelete = data.permissions["admin.users.delete"] === true;
$: canEditRoles = data.permissions["user.roles.other"] === true;
$: canEditPermissions = data.permissions["user.permissions.other"] === true;
$: users = data.users;
$: allRoles = data.roles;
// Search / filter
let searchQuery = "";
$: filteredUsers = users.filter((u) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.login.toLowerCase().includes(q)
);
});
// Expanded row state
let expandedUserId: string | null = null;
function toggleUser(id: string) {
expandedUserId = expandedUserId === id ? null : id;
}
// Three-dot menu
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
// Edit modal state
let editingUser: UserWithRoles | null = null;
function openEdit(u: UserWithRoles) {
editingUser = u;
openMenuId = null;
}
function cancelEdit() {
editingUser = null;
}
// Delete confirmation
let userToDelete: UserWithRoles | null = null;
let isDeleting = false;
let deleteError = "";
function openDeleteConfirm(u: UserWithRoles) {
userToDelete = u;
openMenuId = null;
}
function cancelDelete() {
userToDelete = null;
deleteError = "";
}
const handleDeleteEnhance: SubmitFunction = () => {
isDeleting = true;
deleteError = "";
return async ({ result, update }) => {
isDeleting = false;
if (result.type === "success") {
userToDelete = null;
} else if (result.type === "failure") {
deleteError =
(result.data as { message?: string })?.message ??
"Failed to delete user.";
}
await update();
};
};
function initials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
</script>
<svelte:window on:click={() => (openMenuId = null)} />
{#if !hasAccess}
<AccessDenied
message="You don't have permission to manage users. Contact your administrator to request access."
/>
{:else if users.length === 0}
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3>No Users Found</h3>
<p>There are no users in the system yet.</p>
</div>
{:else}
<!-- Edit user modal -->
{#if editingUser}
<EditUserModal
user={editingUser}
{allRoles}
permissionNodes={data.permissionNodes}
{canEditRoles}
{canEditPermissions}
onClose={cancelEdit}
onSuccess={cancelEdit}
/>
{/if}
<!-- Delete confirmation dialog -->
<DeleteConfirmDialog
isOpen={!!userToDelete}
title="Delete User"
idValue={userToDelete?.id ?? ""}
formAction="?/deleteUser"
confirmLabel="Delete User"
{isDeleting}
error={deleteError}
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
<h3>
Users
<span class="result-count"
>{filteredUsers.length} user{filteredUsers.length === 1
? ""
: "s"}{#if searchQuery.trim()}
&nbsp;(filtered){/if}</span
>
</h3>
<div class="user-search-wrap">
<svg
class="user-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
class="user-search-input"
placeholder="Search users…"
bind:value={searchQuery}
/>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Login</th>
<th>Roles</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredUsers as user (user.id)}
<tr
class="user-row"
class:expanded={expandedUserId === user.id}
on:click={() => toggleUser(user.id)}
>
<td>
<div class="user-name-cell">
{#if user.image}
<img
src={user.image}
alt={user.name}
class="user-table-avatar"
/>
{:else}
<div class="user-table-avatar user-table-avatar-initials">
{initials(user.name)}
</div>
{/if}
<span class="user-table-name">{user.name}</span>
</div>
</td>
<td>
<EmailText email={user.email} />
</td>
<td>
<span class="user-login-mono">{user.login}</span>
</td>
<td>
<span class="user-role-count">
{user.roleDetails.length} role{user.roleDetails.length === 1
? ""
: "s"}
</span>
</td>
<td>{formatDate(user.createdAt)}</td>
<td class="row-end-cell">
<div class="row-end-content">
{#if canEdit || canDelete}
<div class="menu-wrap">
<button
type="button"
class="menu-btn"
aria-label="User actions"
on:click|stopPropagation={() => toggleMenu(user.id)}
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="2.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13.5" r="1.5" />
</svg>
</button>
{#if openMenuId === user.id}
<div class="user-menu" use:positionMenu>
{#if canEdit}
<button
type="button"
class="user-menu-item"
on:click|stopPropagation={() => openEdit(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canEdit && canDelete}
<div class="user-menu-sep"></div>
{/if}
{#if canDelete}
<button
type="button"
class="user-menu-item user-menu-item-danger"
on:click|stopPropagation={() =>
openDeleteConfirm(user)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
<svg
class="row-chevron"
class:open={expandedUserId === user.id}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M6 9l6 6 6-6" />
</svg>
</div>
</td>
</tr>
{#if expandedUserId === user.id}
<tr class="user-detail-row">
<td colspan="6">
<div class="user-detail-content">
<div class="user-detail-grid">
<!-- User info section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Details
</h4>
<div class="user-detail-fields">
<div class="user-detail-field">
<span class="detail-label">ID</span>
<span class="detail-value detail-mono">{user.id}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Email</span>
<span class="detail-value">
<EmailText email={user.email} />
</span>
</div>
<div class="user-detail-field">
<span class="detail-label">Login</span>
<span class="detail-value detail-mono"
>{user.login}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Created</span>
<span class="detail-value"
>{formatDate(user.createdAt)}</span
>
</div>
<div class="user-detail-field">
<span class="detail-label">Updated</span>
<span class="detail-value"
>{formatDate(user.updatedAt)}</span
>
</div>
</div>
</div>
<!-- Roles section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
Roles
<span class="detail-count"
>{user.roleDetails.length}</span
>
</h4>
{#if user.roleDetails.length === 0}
<p class="user-detail-empty">No roles assigned</p>
{:else}
<div class="user-role-list">
{#each user.roleDetails as role (role.id)}
<div class="user-role-card">
<div class="user-role-card-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
<span class="user-role-title">{role.title}</span
>
<span class="user-role-moniker"
>{role.moniker}</span
>
</div>
{#if role.permissions.length > 0}
<div class="permission-tags">
{#each role.permissions.slice(0, 8) as perm}
<span class="permission-tag">{perm}</span>
{/each}
{#if role.permissions.length > 8}
<span
class="permission-tag permission-tag-more"
>+{role.permissions.length - 8} more</span
>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Additional Permissions section -->
<div class="user-detail-section">
<h4 class="user-detail-heading">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M9 12l2 2 4-4" />
<path
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"
/>
</svg>
Additional Permissions
{#if user.permissions?.length}
<span class="detail-count"
>{user.permissions.length}</span
>
{/if}
</h4>
{#if !user.permissions?.length}
<p class="user-detail-empty">
No additional permissions assigned
</p>
{:else}
<div class="permission-tags">
{#each user.permissions as perm}
<span class="permission-tag">{perm}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if filteredUsers.length === 0 && searchQuery.trim()}
<div class="admin-tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<h3>No Results</h3>
<p>No users match &ldquo;{searchQuery}&rdquo;. Try a different search.</p>
</div>
{/if}
{/if}
@@ -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<string, string>) {
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({});
});
});
});
});
+25
View File
@@ -0,0 +1,25 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/**
* Lightweight endpoint polled by the client every 60 seconds.
*
* The server-side `handle` hook in hooks.server.ts already runs before this
* handler is reached. That hook:
* 1. Validates the access token JWT expiry
* 2. Refreshes the token pair when the access token is within 60 s of expiry
* 3. Redirects to /login (303) if both tokens are unusable
*
* So by the time we get here we know the session is still alive we just
* return a 200 with a minimal body. If the hook redirected, the client fetch
* will see a non-200 (or a redirect to /login) and can react accordingly.
*/
export const GET: RequestHandler = async ({ locals }) => {
const accessToken = locals.session?.accessToken ?? null;
if (!accessToken) {
return json({ authenticated: false }, { status: 401 });
}
return json({ authenticated: true });
};
@@ -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 });
});
});
+44
View File
@@ -0,0 +1,44 @@
import { json } from "@sveltejs/kit";
import { optima } from "$lib";
import type { RequestHandler } from "./$types";
/**
* Token refresh endpoint.
*
* Forces a token refresh using the refresh token cookie regardless of whether
* the server hook already refreshed. This handles cases where the API returns
* a 401 for a token the hook considers still-valid (e.g. revocation, clock
* skew). New tokens are written into cookies so the browser cookie jar is
* always in sync with what the client receives.
*
* Client-side code that receives a 401 from a direct API call can:
* 1. GET /api/auth/refresh receives the fresh accessToken
* 2. Retry the original request with the new token
*/
export const GET: RequestHandler = async ({ locals, cookies }) => {
const refreshToken = cookies.get("refreshToken") ?? null;
if (!refreshToken) {
return json({ error: "Not authenticated" }, { status: 401 });
}
try {
const refreshed = await optima.user.refreshSession(refreshToken);
const newAccessToken: string = refreshed.accessToken;
const newRefreshToken: string = refreshed.refreshToken ?? refreshToken;
// Update cookies so the browser has the latest tokens
cookies.set("accessToken", newAccessToken, { path: "/" });
cookies.set("refreshToken", newRefreshToken, { path: "/" });
// Keep locals in sync for anything downstream in this request
if (locals.session) {
locals.session.accessToken = newAccessToken;
locals.session.refreshToken = newRefreshToken;
}
return json({ accessToken: newAccessToken });
} catch {
return json({ error: "Session expired" }, { status: 401 });
}
};
@@ -0,0 +1,22 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /api/companies/[id]/details — fetch company with contacts and address */
export const GET: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: null }, { status: 401 });
}
try {
const result = await optima.company.fetch(accessToken, params.id, {
includeAllContacts: true,
includeAddress: true,
});
return json({ data: result?.data ?? null });
} catch (err) {
console.error("[api/companies/details] Failed:", err);
return json({ data: null }, { status: 500 });
}
};
@@ -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 });
});
});
@@ -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 });
}
};
@@ -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 });
});
});
@@ -0,0 +1,27 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: [] }, { status: 401 });
}
const search = url.searchParams.get("search") || "";
const page = Number(url.searchParams.get("page")) || 1;
const rpp = Number(url.searchParams.get("rpp")) || 15;
try {
const result = await optima.company.fetchMany(
accessToken,
page,
search,
rpp,
);
return json({ data: result?.data ?? [] });
} catch (err) {
console.error("[api/companies/search] Failed:", err);
return json({ data: [] }, { status: 500 });
}
};
@@ -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 });
});
});
@@ -0,0 +1,22 @@
import type { RequestHandler } from "./$types";
import { api } from "$lib/optima-api/axios";
import { json, error } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
try {
const res = await api.get("/v1/credential-type", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return json(res.data);
} catch (err: unknown) {
const status =
(err as { response?: { status?: number } })?.response?.status ?? 500;
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to load credential types";
throw error(status, message);
}
};
+18
View File
@@ -0,0 +1,18 @@
import { optima } from "$lib";
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return json({ data: [] }, { status: 401 });
}
try {
const members = await optima.cw.fetchMembers(accessToken);
return json({ data: members });
} catch (err) {
console.error("[api/cw/members] Failed:", err);
return json({ data: [] }, { status: 500 });
}
};
@@ -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 });
});
});
@@ -0,0 +1,46 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** POST /api/sales/opportunities — create an opportunity */
export const POST: RequestHandler = async ({ request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const body = await request.json();
if (!body.name?.trim()) throw error(400, "Name is required");
if (!body.expectedCloseDate)
throw error(400, "Expected close date is required");
try {
const result = await optima.sales.createOpportunity(accessToken, {
name: body.name.trim(),
expectedCloseDate: body.expectedCloseDate,
notes: body.notes?.trim() || undefined,
type: body.type || undefined,
stage: body.stage || undefined,
status: body.status || undefined,
priority: body.priority || undefined,
rating: body.rating || undefined,
primarySalesRep: body.primarySalesRep || undefined,
secondarySalesRep: body.secondarySalesRep || undefined,
company: body.company || undefined,
contact: body.contact || undefined,
source: body.source || undefined,
customerPO: body.customerPO || undefined,
});
return json(result, { status: 201 });
} catch (err: unknown) {
console.error("Failed to create opportunity:", 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 create opportunity"
: "Failed to create opportunity";
throw error(status, message);
}
};
@@ -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();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
companies: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
permissions: {},
};
}
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
try {
const [result, permissions] = await Promise.all([
optima.company.fetchMany(accessToken, page, search).catch((err) => {
console.error(
"Failed to fetch companies:",
err?.response?.data ?? err?.message ?? err,
);
return {
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
};
}),
checkPermissions(accessToken, ["companies.view"]),
]);
return {
companies: result?.data ?? [],
totalPages: result?.meta?.pagination?.totalPages ?? 1,
currentPage: result?.meta?.pagination?.currentPage ?? page,
totalRecords:
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
search,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
+317
View File
@@ -0,0 +1,317 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { afterNavigate } from "$app/navigation";
import EmailText from "../../components/EmailText.svelte";
import type { PermissionMap } from "$lib/permissions";
import { formatDate } from "$lib/utils";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import AccessDenied from "../../components/AccessDenied.svelte";
import Pagination from "../../components/Pagination.svelte";
import "../../styles/companies/companylist.css";
export let data: {
permissions: PermissionMap;
companies: Array<{
id: string;
name: string;
status?: string;
type?: string;
createdAt?: string;
identifier?: string;
contactEmail?: string;
[key: string]: unknown;
}>;
totalPages: number;
currentPage: number;
totalRecords: number;
search: string;
};
$: hasAccess = data.permissions["companies.view"] === true;
let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
let isUserTyping = false;
// When navigation completes (results loaded), clear loading & refocus
// Ensure spinner stays visible for at least 500ms
afterNavigate(() => {
const elapsed = Date.now() - searchStartedAt;
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
isUserTyping = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
}, remaining);
});
$: currentPage = data.currentPage;
$: totalPages = data.totalPages;
$: totalRecords = data.totalRecords;
$: companies = data.companies;
function navigateToPage(p: number) {
const params = new URLSearchParams();
params.set("page", String(p));
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`, { replaceState: true });
}
function handleSearch() {
isUserTyping = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
isSearching = true;
isUserTyping = false;
const params = new URLSearchParams();
params.set("page", "1");
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`, {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}, 500);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
isSearching = true;
isUserTyping = false;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
const params = new URLSearchParams();
params.set("page", "1");
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`, {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}
}
function statusClass(status?: string): string {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "disabled") return "inactive";
if (s === "pending") return "pending";
return "neutral";
}
function companyInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
</script>
<svelte:head>
<title>Companies — Project Optima</title>
</svelte:head>
{#if !hasAccess}
<AccessDenied
message="You don't have permission to view Companies. Contact your administrator to request access."
/>
{:else}
<div class="companies-page">
<div class="companies-pane">
<!-- Pane header -->
<div class="pane-header">
<div class="pane-header-left">
<h2 class="page-title">Companies</h2>
{#if totalRecords > 0}
<span class="result-count"
>{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
>
{/if}
</div>
<div class="search-bar">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search companies…"
bind:this={searchInputEl}
bind:value={searchInput}
on:input={handleSearch}
on:keydown={handleKeydown}
/>
{#if searchInput}
<button
class="search-clear"
on:click={() => {
searchInput = "";
handleSearch();
}}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Pane body -->
<div class="pane-body">
{#if isSearching && !isUserTyping}
<div class="search-loading-overlay">
<div class="search-spinner"></div>
</div>
{/if}
{#if companies.length === 0}
<div class="empty-state">
<NoResultsMonkey
message={searchInput
? "No companies match your search"
: "No companies found"}
/>
</div>
{:else}
<div class="card-grid">
{#each companies as company (company.id)}
<button
class="company-card"
on:click={() => goto(`/companies/${company.id}`)}
on:keydown={(e) => {
if (e.key === "Enter") goto(`/companies/${company.id}`);
}}
>
<!-- Card header: avatar + status -->
<div class="card-top">
<div class="card-avatar">
<span class="avatar-initials"
>{companyInitials(company.name)}</span
>
</div>
<span
class="status-dot {statusClass(company.status)}"
title={company.status || "Unknown"}
></span>
</div>
<!-- Card body -->
<div class="card-body">
<h3 class="card-name">{company.name}</h3>
{#if company.contactEmail}
<span class="card-email">
<EmailText email={company.contactEmail} />
</span>
{/if}
</div>
<!-- Card meta -->
<div class="card-meta">
{#if company.type}
<div class="meta-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="meta-icon"
>
<rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 3h-8l-2 4h12z" />
</svg>
<span>{company.type}</span>
</div>
{/if}
{#if company.identifier || company.id}
<div class="meta-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="meta-icon"
>
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
</svg>
<span class="mono"
>{company.identifier || company.id?.slice(0, 8)}</span
>
</div>
{/if}
</div>
<!-- Card footer -->
<div class="card-footer">
{#if company.status}
<span class="status-label {statusClass(company.status)}"
>{company.status}</span
>
{/if}
{#if formatDate(company.createdAt)}
<span class="card-date"
>{formatDate(company.createdAt)}</span
>
{/if}
</div>
<!-- Hover arrow -->
<svg
class="card-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
{/each}
</div>
{/if}
</div>
<!-- Pane footer / Pagination -->
{#if totalPages > 1}
<div class="pane-footer">
<span class="page-info">
Page {currentPage} of {totalPages}
</span>
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
</div>
{/if}
</div>
</div>
{/if}
<style>
</style>
@@ -0,0 +1,86 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
company: null,
configurations: [],
credentials: [],
credentialTypes: [],
unifiSites: [],
accessToken: null,
permissions: {} as PermissionMap,
};
}
try {
// Start the permission check separately so company.fetch can begin
// as soon as permissions resolve, without waiting for the other fetches.
const permissionsPromise = checkPermissions(accessToken, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]);
// Kick off all independent data fetches in parallel
const configsPromise = optima.company.fetchConfigurations(
accessToken,
params.id,
);
const credentialsPromise = optima.credential
.fetchByCompany(accessToken, params.id)
.catch(() => ({ data: [] }));
const credentialTypesPromise = optima.credentialType
.fetchMany(accessToken)
.catch(() => ({ data: [] }));
const unifiSitesPromise = optima.unifi
.fetchCompanySites(accessToken, params.id)
.catch(() => ({ data: [] }));
// company.fetch only depends on permissions — start it as soon as
// permissions resolve, don't wait for the other fetches
const companyPromise = permissionsPromise.then((permissions) =>
optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
}),
);
// Now await everything together
const [
permissions,
configsResult,
credentialsResult,
credentialTypesResult,
unifiSitesResult,
companyResult,
] = await Promise.all([
permissionsPromise,
configsPromise,
credentialsPromise,
credentialTypesPromise,
unifiSitesPromise,
companyPromise,
]);
return {
company: companyResult?.data ?? null,
configurations: configsResult?.data ?? [],
credentials: credentialsResult?.data ?? [],
credentialTypes: credentialTypesResult?.data ?? [],
unifiSites: unifiSitesResult?.data ?? [],
accessToken,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
+359
View File
@@ -0,0 +1,359 @@
<script lang="ts">
import "../../../styles/companies/companydetail.css";
import { onMount } from "svelte";
import type { PageData } from "./types";
// Tab components
import CompanySidebar from "./components/CompanySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import CredentialsTab from "./components/CredentialsTab.svelte";
import ConfigurationsTab from "./components/ConfigurationsTab.svelte";
import ContactsTab from "./components/ContactsTab.svelte";
import UniFiTab from "./components/UniFiTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
export let data: PageData;
$: company = data.company;
$: configurations = data.configurations;
$: credentials = data.credentials;
$: credentialTypes = data.credentialTypes;
$: unifiSites = data.unifiSites;
$: accessToken = data.accessToken;
$: permissions = data.permissions;
// Mobile detection
let isMobile = false;
function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
}
onMount(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
// Create credential modal state (bound to CredentialsTab)
let isCreateCredentialOpen = false;
// Link UniFi site modal state (bound to UniFiTab)
let isLinkUnifiOpen = false;
// Tab navigation
const tabs = [
"Overview",
"Credentials",
"Configurations",
"UniFi",
"Contacts",
"Activity",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Mobile nav state: null = show vertical nav menu; set = show tab content
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
activeTab = tab;
mobileActiveTab = tab;
}
function mobileBack() {
mobileActiveTab = null;
}
</script>
<svelte:head>
<title>{company?.name ?? "Company"} — Project Optima</title>
</svelte:head>
<div class="company-detail-page">
<!-- Left pane (1/4) — Company overview -->
<CompanySidebar {company} {permissions} {isMobile} {mobileActiveTab} />
<!-- Mobile vertical nav menu (only visible on mobile when no tab selected) -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each tabs as tab}
<button
class="mobile-nav-item"
on:click={() => selectMobileTab(tab)}
type="button"
>
<span class="mobile-nav-icon">
{#if tab === "Credentials"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
{:else if tab === "Configurations"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="12" cy="12" r="3" /><path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{:else if tab === "UniFi"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" /><path
d="M2 17l10 5 10-5"
/><path d="M2 12l10 5 10-5" />
</svg>
{:else if tab === "Contacts"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{/if}
</span>
<span class="mobile-nav-label">{tab}</span>
{#if tab === "Credentials" && credentials.length > 0}
<span class="mobile-nav-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0}
<span class="mobile-nav-badge">{configurations.length}</span>
{/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="mobile-nav-badge">{unifiSites.length}</span>
{/if}
{#if tab === "Contacts" && (company?.cw_Data?.allContacts?.length ?? 0) > 0}
<span class="mobile-nav-badge"
>{company?.cw_Data?.allContacts?.length}</span
>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/each}
</div>
{/if}
<!-- Right pane (3/4) -->
<div
class="company-detail-right"
class:mobile-hidden={isMobile && mobileActiveTab === null}
>
<!-- Mobile content header with back button -->
{#if isMobile && mobileActiveTab !== null}
<div class="mobile-content-header">
<button
class="mobile-back-btn"
on:click={mobileBack}
type="button"
aria-label="Back to menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
{#if mobileActiveTab === "Credentials"}
<button
type="button"
class="create-credential-btn mobile-create-btn"
on:click={() => (isCreateCredentialOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
New
</button>
{/if}
{#if mobileActiveTab === "UniFi"}
<button
type="button"
class="create-credential-btn mobile-create-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg>
Link
</button>
{/if}
</div>
{/if}
<div class="tab-bar" role="tablist">
{#each tabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{tab}
{#if tab === "Credentials" && credentials.length > 0}
<span class="tab-count-badge">{credentials.length}</span>
{/if}
{#if tab === "Configurations" && configurations.length > 0}
<span class="tab-count-badge">{configurations.length}</span>
{/if}
{#if tab === "UniFi" && unifiSites.length > 0}
<span class="tab-count-badge">{unifiSites.length}</span>
{/if}
</button>
{/each}
{#if activeTab === "Credentials"}
<div class="tab-bar-spacer"></div>
<button
type="button"
class="create-credential-btn"
on:click={() => (isCreateCredentialOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Credential
</button>
{/if}
{#if activeTab === "UniFi"}
<div class="tab-bar-spacer"></div>
<button
type="button"
class="create-credential-btn"
on:click={() => (isLinkUnifiOpen = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"
/>
<path
d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"
/>
</svg>
Link Site
</button>
{/if}
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab {company} {credentials} {configurations} {unifiSites} />
{:else if activeTab === "Credentials"}
<CredentialsTab
companyId={company?.id ?? ""}
{credentials}
{credentialTypes}
{accessToken}
{permissions}
{isMobile}
bind:isCreateCredentialOpen
/>
{:else if activeTab === "Configurations"}
<ConfigurationsTab {configurations} {isMobile} />
{:else if activeTab === "UniFi"}
<UniFiTab
companyId={company?.id ?? ""}
{unifiSites}
{accessToken}
{permissions}
bind:isLinkUnifiOpen
/>
{:else if activeTab === "Contacts"}
<ContactsTab {company} />
{:else if activeTab === "Activity"}
<ActivityTab />
{/if}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<script lang="ts">
// Activity tab — placeholder for future implementation
</script>
<p class="tab-placeholder">Activity content</p>
@@ -0,0 +1,248 @@
<script lang="ts">
import { goto } from "$app/navigation";
import EmailText from "../../../../components/EmailText.svelte";
import {
type CompanyData,
companyInitials,
statusClass,
formatDate,
formatPhone,
formatAddress,
} from "../types";
import type { PermissionMap } from "$lib/permissions";
export let company: CompanyData | null;
export let permissions: PermissionMap;
export let isMobile: boolean;
export let mobileActiveTab: string | null;
</script>
<div
class="company-detail-left"
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
>
<div class="detail-pane-body">
<button
class="back-btn"
on:click={() => goto("/companies")}
aria-label="Back to companies"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{#if company}
<!-- Avatar + name + status -->
<div class="profile-header">
<div class="profile-avatar">
<span class="profile-initials">{companyInitials(company.name)}</span>
</div>
<h3 class="profile-name">{company.name}</h3>
{#if company.status}
<span class="profile-status {statusClass(company.status)}"
>{company.status}</span
>
{/if}
</div>
<!-- Info rows -->
<div class="profile-info">
{#if company.cw_Data?.primaryContact}
{@const contact = company.cw_Data.primaryContact}
<div class="primary-contact-section">
<div class="primary-contact-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span class="primary-contact-label">Primary Contact</span>
</div>
<div class="primary-contact-card">
<div class="primary-contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
{#if contact.inactive}
<span class="primary-contact-inactive">Inactive</span>
{/if}
</div>
{#if contact.title}
<div class="primary-contact-title">{contact.title}</div>
{/if}
{#if contact.email}
<div class="primary-contact-detail">
<EmailText email={contact.email} />
</div>
{/if}
{#if contact.phone}
<div class="primary-contact-detail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/if}
{#if company.type}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 3h-8l-2 4h12z" />
</svg>
<div class="info-content">
<span class="info-label">Type</span>
<span class="info-value">{company.type}</span>
</div>
</div>
{/if}
{#if company.contactEmail}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">
<EmailText email={company.contactEmail} />
</span>
</div>
</div>
{/if}
{#if company.contactPhone}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{formatPhone(company.contactPhone)}</span
>
</div>
</div>
{/if}
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value address-multiline">
{#each formatAddress(company) as line}
{line}<br />
{/each}
</span>
</div>
</div>
{/if}
{#if formatDate(company.createdAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<div class="info-content">
<span class="info-label">Created</span>
<span class="info-value">{formatDate(company.createdAt)}</span>
</div>
</div>
{/if}
{#if formatDate(company.updatedAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div class="info-content">
<span class="info-label">Updated</span>
<span class="info-value">{formatDate(company.updatedAt)}</span>
</div>
</div>
{/if}
</div>
{#if company.identifier || company.id}
<div class="side-pane-identifier">
{company.identifier || company.id}
</div>
{/if}
{:else}
<div class="profile-empty">
<p>Company not found.</p>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,376 @@
<script lang="ts">
import type { ConfigurationData } from "../types";
import { formatDate, configStatusClass } from "../types";
export let configurations: ConfigurationData[];
export let isMobile: boolean;
// Configurations split-view state
let selectedConfig: ConfigurationData | null = null;
let configFadeKey = 0;
// Track which password fields are revealed (by question id)
let revealedPasswords: Record<number, boolean> = {};
function togglePassword(questionId: number) {
revealedPasswords[questionId] = !revealedPasswords[questionId];
revealedPasswords = revealedPasswords;
}
function selectConfig(config: ConfigurationData) {
if (selectedConfig?.id === config.id) {
selectedConfig = null;
} else {
selectedConfig = config;
configFadeKey++;
revealedPasswords = {};
}
}
</script>
{#if configurations.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<p>No configurations found</p>
</div>
{:else}
<div
class="config-split"
class:expanded={selectedConfig !== null && !isMobile}
>
<!-- Left side: config buttons -->
<div
class="config-list"
class:collapsed={selectedConfig !== null && !isMobile}
>
{#each configurations as config (config.id)}
<button
class="config-item"
class:selected={selectedConfig?.id === config.id}
class:config-inactive={config.status?.name === "Inactive" ||
config.status?.name === "Automate Inactive"}
on:click={() => selectConfig(config)}
type="button"
>
<div class="config-item-header">
<div class="config-name-group">
<span
class="config-status-dot dot-{configStatusClass(
config.status?.name,
)}"
title={config.status?.name ?? "Unknown"}
></span>
<span class="config-name">{config.name}</span>
</div>
<div class="config-header-badges">
{#if config.status?.name && (!selectedConfig || isMobile)}
<span
class="config-status-badge status-{configStatusClass(
config.status.name,
)}">{config.status.name}</span
>
{/if}
{#if config.type?.name && (!selectedConfig || isMobile)}
<span class="config-type-badge">{config.type.name}</span>
{/if}
</div>
</div>
{#if !selectedConfig || isMobile}
{#if config.description}
<p class="config-description">{config.description}</p>
{/if}
{#if config.key}
<div class="config-kv">
<span class="config-key">{config.key}</span>
{#if config.value}
<span class="config-value">{config.value}</span>
{/if}
</div>
{/if}
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
<span class="config-date">
{#if formatDate(config.updatedAt)}
Updated {formatDate(config.updatedAt)}
{:else if formatDate(config.info?.lastUpdated)}
Updated {formatDate(config.info?.lastUpdated)}
{:else if formatDate(config.createdAt)}
Created {formatDate(config.createdAt)}
{:else if formatDate(config.info?.dateEntered)}
Created {formatDate(config.info?.dateEntered)}
{/if}
</span>
{/if}
{/if}
</button>
{/each}
</div>
<!-- Right side: config detail panel -->
{#if selectedConfig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bottom-sheet-overlay"
class:active={selectedConfig !== null}
on:click={() => {
selectedConfig = null;
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="bottom-sheet-panel" on:click|stopPropagation>
<div class="bottom-sheet-handle"></div>
<div class="bottom-sheet-body">
<div class="config-detail-panel">
{#key configFadeKey}
<div class="config-detail-content">
<div class="config-detail-header">
<div class="config-detail-header-left">
<h3 class="config-detail-title">
{selectedConfig.name}
</h3>
<div class="config-detail-meta-badges">
{#if selectedConfig.type?.name}
<span class="config-badge type"
>{selectedConfig.type.name}</span
>
{/if}
{#if selectedConfig.status?.name}
<span
class="config-badge status-{configStatusClass(
selectedConfig.status.name,
)}"
>
{selectedConfig.status.name}
</span>
{/if}
</div>
</div>
<button
class="config-detail-close"
on:click={() => (selectedConfig = null)}
aria-label="Close detail view"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{#if selectedConfig.serialNumber}
<div class="config-serial">
<span class="config-serial-label">Serial #</span>
<span class="config-serial-value"
>{selectedConfig.serialNumber}</span
>
</div>
{/if}
<!-- Questions / Fields -->
{#if (selectedConfig.questions && selectedConfig.questions.length > 0) || selectedConfig.notes}
<div class="config-questions">
{#if selectedConfig.notes}
<div class="config-notes">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Notes
</h4>
<p class="config-notes-text">
{selectedConfig.notes}
</p>
</div>
{/if}
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Configuration Details
</h4>
<div class="questions-grid">
{#each selectedConfig.questions as q (q.id)}
<div
class="question-row"
class:has-answer={!!q.answer}
>
<span class="question-label">{q.question}</span>
<div class="question-value-wrap">
{#if q.fieldType === "Password"}
<span class="question-value password-value">
{#if revealedPasswords[q.id]}
{q.answer || "—"}
{:else}
{q.answer ? "••••••••" : "—"}
{/if}
</span>
{#if q.answer}
<button
class="password-toggle"
on:click={() => togglePassword(q.id)}
type="button"
aria-label={revealedPasswords[q.id]
? "Hide password"
: "Show password"}
>
{#if revealedPasswords[q.id]}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
/>
<path
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
/>
<path
d="M14.12 14.12a3 3 0 11-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
{/if}
{:else if q.fieldType === "TextArea"}
<span class="question-value textarea-value"
>{q.answer || "—"}</span
>
{:else}
<span class="question-value"
>{q.answer || "—"}</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if !selectedConfig.notes}
<div class="config-no-questions">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>No configuration fields available</p>
</div>
{/if}
<!-- Footer metadata -->
{#if selectedConfig.info}
<div class="config-info-footer">
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 5v14M5 12h14" />
</svg>
Created{#if selectedConfig.info.enteredBy}&nbsp;by
<strong>{selectedConfig.info.enteredBy}</strong
>{/if}{#if selectedConfig.info.dateEntered}&nbsp;on
{formatDate(selectedConfig.info.dateEntered)}{/if}
</div>
{/if}
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Updated{#if selectedConfig.info.updatedBy}&nbsp;by
<strong>{selectedConfig.info.updatedBy}</strong
>{/if}{#if selectedConfig.info.lastUpdated}&nbsp;on
{formatDate(selectedConfig.info.lastUpdated)}{/if}
</div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,180 @@
<script lang="ts">
import EmailText from "../../../../components/EmailText.svelte";
import type { CompanyData } from "../types";
import { formatPhone } from "../types";
export let company: CompanyData | null;
</script>
{#if !company?.cw_Data?.allContacts || company.cw_Data.allContacts.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
<p>No contacts found</p>
</div>
{:else}
{@const activeContacts = company.cw_Data.allContacts.filter(
(c) => !c.inactive,
)}
{@const inactiveContacts = company.cw_Data.allContacts.filter(
(c) => c.inactive,
)}
<!-- Active contacts -->
{#if activeContacts.length > 0}
<div class="contacts-section">
<div class="contacts-section-header">
<h3 class="contacts-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-muted)"
stroke-width="2"
width="16"
height="16"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Active
</h3>
<span class="contacts-section-count">{activeContacts.length}</span>
</div>
<div class="contacts-grid">
{#each activeContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
<div class="contact-card">
<div class="contact-card-header">
<div class="contact-avatar">
<span class="contact-initials">
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
</span>
</div>
<div class="contact-card-info">
<div class="contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
</div>
{#if contact.title}
<div class="contact-title">{contact.title}</div>
{/if}
</div>
</div>
<div class="contact-card-details">
{#if contact.email}
<div class="contact-detail-row">
<EmailText email={contact.email} />
</div>
{/if}
{#if contact.phone}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Inactive contacts -->
{#if inactiveContacts.length > 0}
<div class="contacts-section contacts-section-inactive">
<div class="contacts-section-header">
<h3 class="contacts-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-muted)"
stroke-width="2"
width="16"
height="16"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="23" y1="13" x2="17" y2="13" />
</svg>
Inactive
</h3>
<span class="contacts-section-count inactive"
>{inactiveContacts.length}</span
>
</div>
<div class="contacts-grid">
{#each inactiveContacts as contact (contact.cwId ?? `${contact.firstName}-${contact.lastName}`)}
<div class="contact-card contact-inactive">
<div class="contact-card-header">
<div class="contact-avatar contact-avatar-inactive">
<span class="contact-initials">
{contact.firstName?.[0] ?? ""}{contact.lastName?.[0] ?? ""}
</span>
</div>
<div class="contact-card-info">
<div class="contact-name">
{[contact.firstName, contact.lastName]
.filter(Boolean)
.join(" ")}
<span class="contact-inactive-badge">Inactive</span>
</div>
{#if contact.title}
<div class="contact-title">{contact.title}</div>
{/if}
</div>
</div>
<div class="contact-card-details">
{#if contact.email}
<div class="contact-detail-row">
<EmailText email={contact.email} />
</div>
{/if}
{#if contact.phone}
<div class="contact-detail-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-secondary)"
stroke-width="2"
width="13"
height="13"
style="min-width:13px;min-height:13px;"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{formatPhone(contact.phone)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
<script lang="ts">
import type { CompanyData, ConfigurationData } from "../types";
import type { Credential } from "$lib/optima-api/modules/credentials";
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
export let company: CompanyData | null;
export let credentials: Credential[];
export let configurations: ConfigurationData[];
export let unifiSites: UnifiSite[];
</script>
<div class="overview-tab">
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Company Details
</h3>
<div class="overview-details-grid">
<div class="overview-detail-item">
<span class="overview-detail-label">Name</span>
<span class="overview-detail-value">{company?.name ?? "—"}</span>
</div>
<div class="overview-detail-item">
<span class="overview-detail-label">ID</span>
<span class="overview-detail-value mono">{company?.id ?? "—"}</span>
</div>
</div>
</div>
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
At a Glance
</h3>
<div class="overview-stats-grid">
<div class="overview-stat-card">
<span class="overview-stat-value">{credentials.length}</span>
<span class="overview-stat-label">Credentials</span>
</div>
<div class="overview-stat-card">
<span class="overview-stat-value">{configurations.length}</span>
<span class="overview-stat-label">Configurations</span>
</div>
<div class="overview-stat-card">
<span class="overview-stat-value">{unifiSites.length}</span>
<span class="overview-stat-label">UniFi Sites</span>
</div>
</div>
</div>
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
Recent Activity
</h3>
<p class="overview-placeholder">Activity feed coming soon.</p>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -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: [],
});
});
});
@@ -0,0 +1,32 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw error(401, "Unauthorized");
}
const credentialId = url.searchParams.get("credentialId");
const fieldId = url.searchParams.get("fieldId");
if (!credentialId || !fieldId) {
throw error(400, "Missing credentialId or fieldId");
}
try {
const result = await optima.credential.fetchSecureValue(
accessToken,
credentialId,
fieldId,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to fetch secure value:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to fetch secure value");
}
};
@@ -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();
});
});
+187
View File
@@ -0,0 +1,187 @@
import type { Credential } from "$lib/optima-api/modules/credentials";
import type { CredentialType } from "$lib/optima-api/modules/credentialTypes";
import type { UnifiSite } from "$lib/optima-api/modules/unifi";
import type { PermissionMap } from "$lib/permissions";
export interface CompanyData {
id: string;
name: string;
status?: string;
type?: string;
createdAt?: string;
updatedAt?: string;
identifier?: string;
contactEmail?: string;
contactPhone?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
cw_Data?: {
address?: {
line1?: string;
line2?: string | null;
city?: string;
state?: string;
zip?: string;
country?: string;
};
primaryContact?: {
firstName?: string;
lastName?: string;
cwId?: number;
inactive?: boolean;
title?: string;
phone?: string;
email?: string;
};
allContacts?: Array<{
firstName?: string;
lastName?: string;
cwId?: number;
inactive?: boolean;
title?: string;
phone?: string;
email?: string;
}>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface ConfigurationData {
id: string | number;
name: string;
active?: boolean;
serialNumber?: string;
status?: {
id: number;
name: string;
};
type?: {
id: number;
name: string;
_info?: { type_href?: string };
};
notes?: string;
questions?: Array<{
id: number;
question: string;
answer?: string;
fieldType: string;
}> | null;
info?: {
lastUpdated?: string;
updatedBy?: string;
dateEntered?: string;
enteredBy?: string;
};
key?: string;
value?: string;
description?: string;
createdAt?: string;
updatedAt?: string;
[key: string]: unknown;
}
export interface PageData {
company: CompanyData | null;
configurations: ConfigurationData[];
credentials: Credential[];
credentialTypes: CredentialType[];
unifiSites: UnifiSite[];
accessToken: string | null;
permissions: PermissionMap;
}
// Shared utility functions
export function companyInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export function statusClass(status?: string): string {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "disabled") return "inactive";
if (s === "pending") return "pending";
return "neutral";
}
export function configStatusClass(statusName?: string): string {
if (!statusName) return "neutral";
const s = statusName.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "automate inactive") return "inactive";
if (s === "reserved") return "reserved";
if (s === "provisioning" || s === "pending approval") return "pending";
return "neutral";
}
export function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
export function formatPhone(phone?: string): string {
if (!phone) return "";
const digits = phone.replace(/\D/g, "");
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
if (digits.length === 11 && digits.startsWith("1")) {
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
}
return phone;
}
export function formatValueTypeLabel(vt: string): string {
return vt
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function formatAddress(c: CompanyData): string[] {
const addr = c.cw_Data?.address;
if (addr) {
const lines: string[] = [];
if (addr.line1) lines.push(addr.line1);
if (addr.line2) lines.push(addr.line2);
const cityStateZip = [addr.city, addr.state, addr.zip]
.filter(Boolean)
.join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (addr.country) lines.push(addr.country);
return lines;
}
const lines: string[] = [];
if (c.address) lines.push(c.address);
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (c.country) lines.push(c.country);
return lines;
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
@@ -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,
});
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async () => {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
+18
View File
@@ -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");
});
});
+85
View File
@@ -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, user: null });
});
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, user: null });
});
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, user: { id: "u1" } });
});
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, user: { id: "u1" } });
});
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, user: null });
});
});
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/procurement.css";
const tabs = [
{ label: "Product Catalog", href: "/procurement/catalog", exact: false },
] as const;
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Procurement — Project Optima</title>
</svelte:head>
<div class="procurement-page">
<div class="procurement-pane">
<!-- Pane header + tabs in one row -->
<div class="procurement-header">
<div class="procurement-header-left">
<svg
class="procurement-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<h2 class="procurement-title">Procurement</h2>
</div>
<nav class="tab-bar" role="tablist">
{#each tabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</nav>
</div>
<!-- Tab content -->
<div class="procurement-body">
<slot />
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
onMount(() => {
goto("/procurement/catalog", { replaceState: true });
});
</script>
@@ -0,0 +1,60 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
items: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
permissions: {},
};
}
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
const includeInactive = url.searchParams.get("includeInactive") === "true";
try {
const [result, permissions] = await Promise.all([
optima.procurement
.fetchMany(accessToken, page, { search, includeInactive }, 30)
.catch((err) => {
console.error(
"Failed to fetch catalog items:",
err?.response?.data ?? err?.message ?? err,
);
return {
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
};
}),
checkPermissions(accessToken, [
"procurement.catalog.fetch.many",
"procurement.catalog.inventory.refresh",
"procurement.catalog.fetch",
"procurement.catalog.link",
]),
]);
return {
items: result?.data ?? [],
totalPages: result?.meta?.pagination?.totalPages ?? 1,
currentPage: result?.meta?.pagination?.currentPage ?? page,
totalRecords:
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
search,
includeInactive,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /procurement/catalog/linked?id=<identifier> — fetch linked items */
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const identifier = url.searchParams.get("id");
if (!identifier) throw error(400, "Missing item id");
try {
const result = await optima.procurement.fetchLinkedItems(
accessToken,
identifier,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to fetch linked items:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to fetch linked items");
}
};
/** POST /procurement/catalog/linked — link or unlink items */
export const POST: RequestHandler = async ({ request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const body = await request.json();
const { action, identifier, targetId } = body;
if (!identifier || !targetId || !action) {
throw error(400, "Missing identifier, targetId, or action");
}
try {
if (action === "link") {
const result = await optima.procurement.linkItem(
accessToken,
identifier,
targetId,
);
return json(result);
} else if (action === "unlink") {
const result = await optima.procurement.unlinkItem(
accessToken,
identifier,
targetId,
);
return json(result);
} else {
throw error(400, "Invalid action — must be 'link' or 'unlink'");
}
} catch (err: unknown) {
console.error(`Failed to ${action} items:`, err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, `Failed to ${action} items`);
}
};
@@ -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 }),
);
});
});
});
@@ -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,
);
});
});
@@ -0,0 +1,29 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /procurement/catalog/search?q=<query> — search catalog items for linking */
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const query = url.searchParams.get("q") || "";
if (!query.trim()) return json({ data: [] });
try {
const result = await optima.procurement.fetchMany(
accessToken,
1,
{ search: query, includeInactive: true },
20,
);
return json({ data: result?.data ?? [] });
} catch (err: unknown) {
console.error("Failed to search catalog items:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to search catalog items");
}
};
@@ -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();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
opportunities: [],
opportunityTypes: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
includeClosed: false,
dashSearch: "",
dashIncludeClosed: false,
permissions: {},
tab: "dashboard",
};
}
const tab = url.searchParams.get("tab") ?? "dashboard";
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
const includeClosed = url.searchParams.get("includeClosed") !== "false";
const dashSearch = "";
const dashIncludeClosed = url.searchParams.get("dashIncludeClosed") === "true";
const rpp = 30;
console.log("[sales load] tab:", tab, "dashIncludeClosed:", dashIncludeClosed, "url:", url.toString());
try {
const [result, meResult, permissions, opportunityTypesResult, metrics] =
await Promise.all([
tab !== "dashboard"
? optima.sales
.fetchMany(accessToken, page, search, rpp, includeClosed)
.catch((err) => {
console.error(
"Failed to fetch opportunities:",
err?.response?.data ?? err?.message ?? err,
);
return {
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
};
})
: null,
tab === "dashboard"
? optima.sales.fetchMe(accessToken, dashIncludeClosed).catch((err) => {
console.error(
"Failed to fetch my opportunities:",
err?.response?.data ?? err?.message ?? err,
);
return { data: [] };
})
: null,
checkPermissions(accessToken, [
"sales.opportunity.fetch.many",
"sales.opportunity.create",
"sales.opportunity.fetch.@me",
"sales.opportunity.fetch.all",
]),
optima.sales
.fetchOpportunityTypes(accessToken)
.catch(() => ({ data: [] })),
optima.sales.fetchMetrics(accessToken).catch(() => null),
]);
const opportunities =
tab === "dashboard"
? (meResult?.data?.data ?? meResult?.data?.opportunities ?? meResult?.data ?? [])
: (result?.data?.data ?? result?.data?.opportunities ?? result?.data ?? []);
const pagination =
result?.meta?.pagination ?? result?.data?.meta?.pagination ?? null;
return {
opportunities,
opportunityTypes: opportunityTypesResult?.data ?? [],
totalPages: pagination?.totalPages ?? 1,
currentPage: pagination?.currentPage ?? page,
totalRecords: pagination?.totalRecords ?? opportunities.length ?? 0,
search,
includeClosed,
dashSearch,
dashIncludeClosed,
permissions,
tab,
metrics,
};
} catch (err) {
handleApiError(err);
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
<script lang="ts">
import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales";
import { formatDate } from "$lib/utils";
import {
statusColorClass,
isEquivalencyStatus,
originalStatusName,
} from "$lib/sales-utils";
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import Pagination from "../../../components/Pagination.svelte";
import "../../../styles/sales/sales.css";
type SalesOpportunity = {
id: string;
cwOpportunityId?: number;
name: string;
type?: { id?: number; name?: string } | null;
stage?: { id?: number; name?: string } | null;
status?: { id?: number; name?: string } | null;
priority?: { id?: number; name?: string } | null;
rating?: { id?: number; name?: string } | null;
primarySalesRep?: {
id?: number;
identifier?: string;
name?: string;
} | null;
secondarySalesRep?: {
id?: number;
identifier?: string;
name?: string;
} | null;
company?: { id?: number | string; name?: string } | null;
expectedCloseDate?: string | null;
closedDate?: string | null;
closedFlag?: boolean;
cwLastUpdated?: string | null;
createdAt?: string;
updatedAt?: string;
};
export let data: {
permissions: PermissionMap;
opportunities: SalesOpportunity[];
opportunityTypes: OpportunityType[];
totalPages: number;
currentPage: number;
totalRecords: number;
search: string;
includeClosed: boolean;
};
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
$: canCreate = data.permissions["sales.opportunity.create"] === true;
let showCreateModal = false;
// Build lookup maps for opportunity type resolution
// directMap: type id → OpportunityType (exact match)
// equivMap: type id → OpportunityType (matched via optimaEquivalency)
$: directMap = new Map<number, OpportunityType>(
data.opportunityTypes.map((t) => [t.id, t]),
);
$: equivMap = (() => {
const m = new Map<number, OpportunityType>();
for (const t of data.opportunityTypes) {
if (t.optimaEquivalency) {
for (const eqId of t.optimaEquivalency) {
m.set(eqId, t);
}
}
}
return m;
})();
let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
let isUserTyping = false;
let lastAutoOpenSearch = "";
let autoOpenTimer: ReturnType<typeof setTimeout>;
let showClosed = data.includeClosed;
let filterOpen = false;
let filterBtnEl: HTMLButtonElement;
let filterPopoverEl: HTMLDivElement;
function toggleFilterPopover() {
filterOpen = !filterOpen;
}
function handleFilterClickOutside(e: MouseEvent) {
if (!filterOpen) return;
const target = e.target as Node;
if (filterBtnEl?.contains(target) || filterPopoverEl?.contains(target))
return;
filterOpen = false;
}
function toggleClosed() {
showClosed = !showClosed;
filterOpen = false;
navigateWithFilters({ page: 1 });
}
$: activeFilterCount = showClosed ? 0 : 1;
afterNavigate(() => {
const elapsed = Date.now() - searchStartedAt;
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
isUserTyping = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
}, remaining);
});
// Auto-open: when the server-confirmed search returns exactly 1 result
// whose CW opportunity ID matches the search term, navigate to it.
$: {
const serverSearch = data.search?.trim() || "";
if (
serverSearch &&
serverSearch !== lastAutoOpenSearch &&
data.opportunities.length === 1
) {
const opp = data.opportunities[0];
const cwId =
opp.cwOpportunityId != null ? String(opp.cwOpportunityId) : null;
const normalized = serverSearch.replace(/^(?:cw\s*#?|#)\s*/i, "");
if (cwId && normalized === cwId) {
lastAutoOpenSearch = serverSearch;
clearTimeout(autoOpenTimer);
autoOpenTimer = setTimeout(() => {
goto(`/sales/opportunity/${opp.id}`);
}, 600);
}
}
}
$: currentPage = data.currentPage;
$: totalPages = data.totalPages;
$: totalRecords = data.totalRecords;
$: opportunities = data.opportunities;
function navigateWithFilters(
opts: { page?: number; keepFocus?: boolean } = {},
) {
const params = new URLSearchParams();
params.set("page", String(opts.page ?? currentPage));
if (searchInput) params.set("search", searchInput);
if (!showClosed) params.set("includeClosed", "false");
goto(`/sales/opportunities?${params.toString()}`, {
replaceState: true,
keepFocus: opts.keepFocus ?? false,
noScroll: true,
});
}
function navigateToPage(p: number) {
navigateWithFilters({ page: p });
}
function handleSearch() {
isUserTyping = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
clearTimeout(autoOpenTimer);
debounceTimer = setTimeout(() => {
isSearching = true;
isUserTyping = false;
navigateWithFilters({ page: 1, keepFocus: true });
}, 500);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
isSearching = true;
isUserTyping = false;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
clearTimeout(autoOpenTimer);
navigateWithFilters({ page: 1, keepFocus: true });
}
}
function statusLabel(op: SalesOpportunity): string {
if (op.closedFlag) return "Closed";
const statusId = op.status?.id;
if (statusId != null) {
if (directMap.has(statusId)) {
return directMap.get(statusId)!.name;
}
if (equivMap.has(statusId)) {
return equivMap.get(statusId)!.name + " *";
}
// Debug: log unmatched status IDs so we can see what's not resolving
console.log("[Status Debug] Unmatched status", {
oppName: op.name,
statusId,
statusName: op.status?.name,
directMapKeys: [...directMap.keys()],
equivMapKeys: [...equivMap.keys()],
});
}
return op.status?.name || "Open";
}
function ownerLabel(op: SalesOpportunity): string {
return op.primarySalesRep?.name || op.secondarySalesRep?.name || "—";
}
function companyLabel(op: SalesOpportunity): string {
return op.company?.name || "—";
}
function ratingHeatClass(name: string | undefined): string {
if (!name) return "heat-neutral";
const n = name.toLowerCase();
if (n.includes("hot")) return "heat-hot";
if (n.includes("warm") || n.includes("medium")) return "heat-warm";
if (n.includes("cold") || n.includes("cool")) return "heat-cold";
return "heat-neutral";
}
function ratingHeatLevel(name: string | undefined): number {
if (!name) return 0;
const n = name.toLowerCase();
if (n.includes("hot")) return 3;
if (n.includes("warm") || n.includes("medium")) return 2;
if (n.includes("cold") || n.includes("cool")) return 1;
return 0;
}
function heatDotColor(name: string | undefined): string {
if (!name) return "rgba(128,128,128,0.4)";
const n = name.toLowerCase();
if (n.includes("hot")) return "#ef4444";
if (n.includes("warm") || n.includes("medium")) return "#f59e0b";
if (n.includes("cold") || n.includes("cool")) return "#38bdf8";
return "rgba(128,128,128,0.4)";
}
function getDotStyle(level: number, ratingName: string | undefined): string {
const lvl = ratingHeatLevel(ratingName);
const filled = level <= lvl;
if (!filled)
return "background: rgba(128,128,128,0.25); width: 7px; height: 7px; border-radius: 50%; display: inline-block;";
const color = heatDotColor(ratingName);
let s = `background: ${color}; width: 7px; height: 7px; border-radius: 50%; display: inline-block;`;
if (ratingHeatClass(ratingName) === "heat-hot")
s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s;
}
</script>
<svelte:window on:click={handleFilterClickOutside} />
<svelte:head>
<title>Sales — Project Optima</title>
</svelte:head>
{#if !hasAccess}
<AccessDenied
message="You don't have permission to view Sales opportunities. Contact your administrator to request access."
/>
{:else}
<div class="sales-page">
<div class="sales-pane">
<div class="sales-header">
<div class="sales-header-left">
<h2 class="sales-title">Sales Opportunities</h2>
{#if totalRecords > 0}
<span class="sales-result-count">
{totalRecords} record{totalRecords === 1 ? "" : "s"}
</span>
{/if}
</div>
<div class="sales-header-actions">
<div class="sales-search-bar">
<svg
class="sales-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search opportunities…"
bind:this={searchInputEl}
bind:value={searchInput}
on:input={handleSearch}
on:keydown={handleKeydown}
/>
{#if searchInput}
<button
class="sales-search-clear"
on:click={() => {
searchInput = "";
handleSearch();
}}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{#if canCreate}
<button
class="sales-create-btn"
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Opportunity
</button>
{/if}
<div class="sales-filter-wrap">
<button
class="sales-filter-btn"
class:has-filters={activeFilterCount > 0}
bind:this={filterBtnEl}
on:click={toggleFilterPopover}
aria-label="Filters"
aria-expanded={filterOpen}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filters
{#if activeFilterCount > 0}
<span class="sales-filter-badge">{activeFilterCount}</span>
{/if}
</button>
{#if filterOpen}
<div class="sales-filter-popover" bind:this={filterPopoverEl}>
<label class="sales-filter-option">
<input
type="checkbox"
checked={showClosed}
on:change={toggleClosed}
/>
<span>Include closed opportunities</span>
</label>
</div>
{/if}
</div>
</div>
</div>
<div class="sales-body">
<div class="sales-table-wrap">
{#if opportunities.length === 0}
<div class="sales-empty">
<NoResultsMonkey
message={searchInput
? "No opportunities match your search"
: "No opportunities found"}
/>
</div>
{:else}
<table class="sales-table">
<thead>
<tr>
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
</tr>
</thead>
<tbody>
{#each opportunities as opp (opp.id)}
<tr
class="sales-row"
class:closed-row={opp.closedFlag}
on:click={() => goto(`/sales/opportunity/${opp.id}`)}
style="cursor: pointer;"
>
<td class="col-opportunity">
<div class="sales-opportunity">
<span class="opp-name">{opp.name}</span>
{#if opp.cwOpportunityId}
<span class="opp-meta mono"
>CW #{opp.cwOpportunityId}</span
>
{/if}
</div>
</td>
<td class="col-company">{companyLabel(opp)}</td>
<td class="col-status">
<span
class="sales-status-badge {statusColorClass(opp)}"
class:status-equiv={isEquivalencyStatus(opp)}
data-tooltip={isEquivalencyStatus(opp)
? `Original: ${originalStatusName(opp)}`
: undefined}
>
{statusLabel(opp)}
</span>
</td>
<td class="col-rating">
{#if opp.rating?.name}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
></span>
{/each}
</span>
{opp.rating.name}
</span>
{:else}
{/if}
</td>
<td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close">
{formatDate(opp.expectedCloseDate)}
</td>
<td class="col-updated">
{formatDate(opp.cwLastUpdated || opp.updatedAt)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
{#if totalPages > 1}
<div class="sales-footer">
<span class="sales-page-info">
Page {currentPage} of {totalPages}
</span>
<Pagination {currentPage} {totalPages} onNavigate={navigateToPage} />
</div>
{/if}
</div>
</div>
{/if}
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
/>
<style>
.sales-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 1px 4px -1px rgba(59, 130, 246, 0.3);
white-space: nowrap;
}
.sales-create-btn:hover {
filter: brightness(1.08);
box-shadow: 0 3px 10px -2px rgba(59, 130, 246, 0.35);
transform: translateY(-0.5px);
}
.sales-create-btn:active {
transform: translateY(0);
}
</style>
@@ -0,0 +1,91 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
opportunity: null,
notes: [],
contacts: [],
products: [],
quotes: [],
accessToken: null,
permissions: {} as PermissionMap,
workflowStatus: null,
};
}
try {
const [result, permissions, workflowResult] = await Promise.all([
optima.sales.fetchOne(accessToken, params.id, [
"notes",
"contacts",
"products",
"quotes",
]),
checkPermissions(accessToken, [
"sales.opportunity.fetch",
"sales.opportunity.refresh",
"sales.opportunity.note.create",
"sales.opportunity.note.update",
"sales.opportunity.note.delete",
"sales.opportunity.quote.fetch",
"sales.opportunity.quote.commit",
"sales.opportunity.quote.preview",
"sales.opportunity.quote.download",
"sales.opportunity.quote.fetch_downloads",
"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");
const { resolve } = await import("path");
writeFileSync(
resolve("opportunity-debug.json"),
JSON.stringify(result, null, 2),
);
const opportunity = result?.data ?? null;
const notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? [];
const quotes = result?.data?.quotes ?? [];
const workflowStatus = workflowResult?.data ?? null;
return {
opportunity,
opportunityId: params.id,
notes,
contacts,
products,
quotes,
accessToken,
permissions,
workflowStatus,
};
} catch (err) {
handleApiError(err);
}
};
@@ -0,0 +1,412 @@
<script lang="ts">
import "../../../../styles/sales/opportunitydetail.css";
import { onMount } from "svelte";
import { invalidateAll } from "$app/navigation";
import type { PageData } from "./types";
// Tab components
import OpportunitySidebar from "./components/OpportunitySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import NotesTab from "./components/NotesTab.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";
import UnsavedChangesDialog from "../../../../components/UnsavedChangesDialog.svelte";
export let data: PageData;
$: opportunity = data.opportunity;
$: opportunityId = data.opportunityId;
$: notes = data.notes;
$: contacts = data.contacts;
$: 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;
$: if (Array.isArray(opportunity?.productSequence)) {
localProductSequence = opportunity.productSequence;
}
// Mobile detection
let isMobile = false;
function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
}
onMount(() => {
checkMobile();
console.log("[OpportunityLoad] Description values:", {
description: (opportunity as Record<string, unknown> | null)?.description,
notes: opportunity?.notes ?? null,
name: opportunity?.name ?? null,
opportunityId,
});
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
// Tab navigation
const tabs = [
"Overview",
"Products",
"Quotes",
"Notes",
"Activity",
"Reports",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Hide Quotes tab if user lacks fetch permission
// 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;
// Unsaved order changes dialog
let showOrderGuardDialog = false;
let pendingTabAction: (() => void) | null = null;
function confirmDiscard() {
const action = pendingTabAction;
pendingTabAction = null;
showOrderGuardDialog = false;
productsEditing = false;
action?.();
}
function cancelDiscard() {
showOrderGuardDialog = false;
pendingTabAction = null;
}
function guardedAction(action: () => void) {
if (productsEditing) {
pendingTabAction = action;
showOrderGuardDialog = true;
} else {
action();
}
}
/** Guard: block tab switch if ProductsTab has unsaved edits */
function guardedSetTab(tab: Tab) {
if (activeTab === tab) return;
guardedAction(() => { activeTab = tab; });
}
// 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<number>) {
pendingProductId = e.detail;
guardedSetTab("Products");
}
function handleViewQuote(e: CustomEvent<string>) {
pendingQuoteId = e.detail;
guardedSetTab("Quotes");
}
function handleSequenceSaved(e: CustomEvent<number[]>) {
localProductSequence = e.detail;
}
function handleProductsChanged(e: CustomEvent<PageData["products"]>) {
products = e.detail;
}
function handleQuotesChanged(
e: CustomEvent<import("$lib/optima-api/modules/sales").CommittedQuote[]>,
) {
quotes = e.detail;
}
// Mobile nav state
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
guardedAction(() => { activeTab = tab; mobileActiveTab = tab; });
}
function mobileBack() {
guardedAction(() => { mobileActiveTab = null; });
}
</script>
<svelte:head>
<title>{opportunity?.name ?? "Opportunity"} — Project Optima</title>
</svelte:head>
<div class="opportunity-detail-page">
<!-- Left pane — Opportunity overview -->
<OpportunitySidebar
{opportunity}
{isMobile}
{mobileActiveTab}
{permissions}
{isClosedOpportunity}
{workflowStatus}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
<!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each visibleTabs as tab}
<button
class="mobile-nav-item"
on:click={() => selectMobileTab(tab)}
type="button"
>
<span class="mobile-nav-icon">
{#if tab === "Products"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
{:else if tab === "Notes"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/><polyline points="14 2 14 8 20 8" /><line
x1="16"
y1="13"
x2="8"
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" />
</svg>
{:else if tab === "Quotes"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{/if}
</span>
<span class="mobile-nav-label">{tab}</span>
{#if tab === "Products" && products.length > 0}
<span class="mobile-nav-badge">{products.length}</span>
{/if}
{#if tab === "Notes" && notes.length > 0}
<span class="mobile-nav-badge">{notes.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="mobile-nav-badge">{quotes.length}</span>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/each}
</div>
{/if}
<!-- Right pane -->
<div
class="opportunity-detail-right"
class:mobile-hidden={isMobile && mobileActiveTab === null}
>
<!-- Mobile content header with back button -->
{#if isMobile && mobileActiveTab !== null}
<div class="mobile-content-header">
<button
class="mobile-back-btn"
on:click={mobileBack}
type="button"
aria-label="Back to menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
</div>
{/if}
<div class="tab-bar" role="tablist">
{#each visibleTabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => guardedSetTab(tab)}
>
{tab}
{#if tab === "Products" && products.length > 0}
<span class="tab-count-badge">{products.length}</span>
{/if}
{#if tab === "Notes" && notes.length > 0}
<span class="tab-count-badge">{notes.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="tab-count-badge">{quotes.length}</span>
{/if}
</button>
{/each}
<!-- Workflow actions pushed to right side of tab bar -->
{#if opportunity && opportunityId}
<WorkflowPanel
{opportunity}
{workflowStatus}
{permissions}
{opportunityId}
{quotes}
activities={opportunity?.activities ?? []}
inline={true}
on:workflowChanged={() => invalidateAll()}
/>
{/if}
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab
{opportunity}
{notes}
{contacts}
{products}
{permissions}
on:selectProduct={handleSelectProduct}
on:switchTab={(e) => guardedSetTab(e.detail as Tab)}
/>
{:else if activeTab === "Products"}
<ProductsTab
{products}
accessToken={data.accessToken}
{opportunityId}
productSequence={localProductSequence}
initialProductId={pendingProductId}
{permissions}
{isClosedOpportunity}
salesTaxRate={opportunity?.expectedSalesTaxRate ?? null}
bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
/>
{:else if activeTab === "Quotes"}
<QuotesTab
accessToken={data.accessToken}
opportunityId={data.opportunityId}
initialQuotes={quotes}
initialQuoteId={pendingQuoteId}
{permissions}
{isClosedOpportunity}
on:quotesChanged={handleQuotesChanged}
/>
{:else if activeTab === "Notes"}
<NotesTab
{notes}
{permissions}
{opportunityId}
{isClosedOpportunity}
on:notesChanged={() => {
invalidateAll();
}}
/>
{:else if activeTab === "Activity"}
<ActivityTab
{opportunityId}
activities={opportunity?.activities ?? []}
on:viewQuote={handleViewQuote}
/>
{:else if activeTab === "Reports"}
<OpportunityReportsTab {opportunity} {workflowStatus} {opportunityId} />
{/if}
</div>
</div>
</div>
<UnsavedChangesDialog
isOpen={showOrderGuardDialog}
title="Unsaved Order Changes"
message="You have unsaved product order changes. Discard them and continue?"
onDiscard={confirmDiscard}
onCancel={cancelDiscard}
/>
@@ -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");
}
};
@@ -0,0 +1,444 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import { clientFetch } from "$lib/client-fetch";
import type {
WorkflowHistoryEntry,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import { formatDateTime } from "../types";
const dispatch = createEventDispatcher<{ viewQuote: string }>();
export let opportunityId: string;
export let activities: OpportunityActivity[] = [];
let workflowHistory: WorkflowHistoryEntry[] = [];
let isLoading = true;
let loadError: string | null = null;
onMount(() => {
fetchHistory();
});
async function fetchHistory() {
isLoading = true;
loadError = null;
try {
const json = await clientFetch<{
data: { activities: WorkflowHistoryEntry[] };
}>(`/sales/opportunity/${opportunityId}/workflow/history`);
workflowHistory = json.data?.activities ?? [];
} catch (err) {
loadError =
err instanceof Error ? err.message : "Failed to load workflow history";
} finally {
isLoading = false;
}
}
export function refresh() {
fetchHistory();
}
/** Extract the Optima_Type from custom fields */
function getOptimaType(activity: OpportunityActivity): string | null {
const cf = activity.customFields?.find(
(f) => f.id === 45 || f.caption === "Optima_Type",
);
return cf?.value ?? null;
}
/** Get an icon for the Optima_Type */
function optimaTypeIcon(type: string | null): string {
switch (type) {
case "Opportunity Created":
return "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z";
case "Opportunity Setup":
return "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z";
case "Opportunity Review":
return "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 100 6 3 3 0 000-6z";
case "Quote Sent":
return "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z";
case "Quote Confirmed":
return "M22 11.08V12a10 10 0 11-5.93-9.14M22 4L12 14.01l-3-3";
case "Quote Sent & Confirmed":
return "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7zM9 12l2 2 4-4";
case "Revision":
return "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z";
case "Finalized":
return "M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11";
case "Converted":
return "M17 1l4 4-4 4M3 11V9a4 4 0 014-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 01-4 4H3";
case "Quote Generated":
return "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8";
default:
return "M12 2v20M17 7l-5 5-5-5M2 12h20M7 17l5-5 5 5";
}
}
/** CSS class for Optima_Type badge */
function optimaTypeBadgeClass(type: string | null): string {
switch (type) {
case "Opportunity Created":
return "at-type-created";
case "Opportunity Setup":
return "at-type-setup";
case "Opportunity Review":
return "at-type-review";
case "Quote Sent":
return "at-type-sent";
case "Quote Confirmed":
return "at-type-confirmed";
case "Quote Sent & Confirmed":
return "at-type-sent-confirmed";
case "Revision":
return "at-type-revision";
case "Finalized":
return "at-type-finalized";
case "Converted":
return "at-type-converted";
case "Quote Generated":
return "at-type-generated";
default:
return "at-type-default";
}
}
/** Determine if an activity is system-generated (no assignTo or automation-generated) */
function isSystemActivity(activity: OpportunityActivity): boolean {
return !activity.assignTo?.name && !activity.cwEnteredBy;
}
/** Format the assigned user display */
function assignedDisplay(activity: OpportunityActivity): string {
if (isSystemActivity(activity)) return "System";
return activity.assignTo?.name ?? activity.cwEnteredBy ?? "Unknown";
}
/** Check if activity is a status transition (has CW status change in notes) */
function parseStatusTransition(
notes: string | undefined,
): { from: string; to: string } | null {
if (!notes) return null;
// The workflow engine embeds status transitions in notes like: [StatusA → StatusB]
const match = notes.match(/\[(\w+)\s*→\s*(\w+)\]/);
if (match) return { from: match[1], to: match[2] };
return null;
}
/** Check if activity is open (closed flag first, then status id, then dateEnd fallback) */
function isOpenActivity(activity: OpportunityActivity): boolean {
if (activity.closed != null) return !activity.closed;
if (activity.status?.id != null) return activity.status.id !== 2;
return !activity.dateEnd;
}
/** Check if activity is late (dateEnd > dueDate-equivalent) */
function isLateActivity(_activity: OpportunityActivity): {
late: boolean;
days: number;
} {
// Activities use dateStart/dateEnd, not dueDate. Check if closed after scheduled end.
return { late: false, days: 0 };
}
/** Combine CW activities with workflow history into a unified timeline, sorted by creation date */
$: timelineItems = (() => {
let items;
// If we have workflow history, use that (it's already filtered + enriched)
if (workflowHistory.length > 0) {
items = workflowHistory.map((h) => ({
activity: h.activity,
optimaType: h.optimaType,
quoteId: h.quoteId ?? null,
closed: h.closed ?? null,
closedAt: h.closedAt ?? null,
isWorkflow: true,
}));
} else {
// Fall back to raw activities
items = (activities ?? []).map((a) => ({
activity: a,
optimaType: getOptimaType(a),
quoteId: null as string | null,
closed: null as boolean | null,
closedAt: null as string | null,
isWorkflow: false,
}));
}
// Sort by creation date (cwDateEntered or dateStart), newest first
return items.sort((a, b) => {
const dateA = a.activity.cwDateEntered ?? a.activity.dateStart ?? "";
const dateB = b.activity.cwDateEntered ?? b.activity.dateStart ?? "";
return dateB.localeCompare(dateA);
});
})();
</script>
<div class="activity-tab">
<div class="at-header">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
</svg>
Workflow Activity
</h3>
<button
class="at-refresh-btn"
on:click={fetchHistory}
disabled={isLoading}
title="Refresh"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class:at-spinning={isLoading}
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"
/>
</svg>
</button>
</div>
{#if isLoading && timelineItems.length === 0}
<div class="at-loading">
<span class="wf-spinner"></span>
<span>Loading activity history...</span>
</div>
{:else if loadError}
<div class="at-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{loadError}</span>
</div>
{:else if timelineItems.length === 0}
<div class="at-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
</svg>
<p>No workflow activity yet.</p>
</div>
{:else}
<div class="at-timeline">
{#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)}
<div class="at-entry" class:at-entry-open={open}>
<!-- Timeline connector -->
<div class="at-connector">
<div
class="at-dot"
class:at-dot-open={open}
class:at-dot-system={isSystemActivity(act)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d={optimaTypeIcon(item.optimaType)} />
</svg>
</div>
{#if i < timelineItems.length - 1}
<div class="at-line"></div>
{/if}
</div>
<!-- Entry content -->
<div class="at-content">
<div class="at-content-header">
<!-- Optima type badge -->
{#if item.optimaType}
<span
class="at-type-badge {optimaTypeBadgeClass(item.optimaType)}"
>
{item.optimaType}
</span>
{/if}
<!-- Status transition pill -->
{#if transition}
<span class="at-transition-pill">
<span class="at-transition-from">{transition.from}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
<span class="at-transition-to">{transition.to}</span>
</span>
{/if}
<!-- Open indicator -->
{#if open}
<span class="at-open-badge">Open</span>
{/if}
</div>
<!-- Activity name -->
{#if act.name}
<p class="at-name">{act.name}</p>
{/if}
<!-- Notes body -->
{#if act.notes}
<p class="at-notes">{act.notes}</p>
{/if}
<!-- Meta row -->
<div class="at-meta">
<span class="at-user" class:at-system={isSystemActivity(act)}>
{#if isSystemActivity(act)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
ry="2"
/><line x1="8" y1="21" x2="16" y2="21" /><line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
{/if}
{assignedDisplay(act)}
</span>
{#if act.cwDateEntered}
<span class="at-timestamp"
>{formatDateTime(act.cwDateEntered)}</span
>
{:else if act.dateStart}
<span class="at-timestamp">{formatDateTime(act.dateStart)}</span
>
{/if}
{#if item.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(item.closedAt)}
</span>
{:else if act.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.closedAt)}
</span>
{:else if act.dateEnd}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.dateEnd)}
</span>
{/if}
</div>
<!-- Quote reference sub-item -->
{#if item.quoteId && item.optimaType === "Quote Generated"}
<button
class="at-quote-link"
on:click={() =>
item.quoteId && dispatch("viewQuote", item.quoteId)}
title="View quote"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="at-quote-link-label">{item.quoteId}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
class="at-quote-link-arrow"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { OpportunityContact } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let contacts: OpportunityContact[];
</script>
<div class="contacts-tab">
{#if contacts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No contacts associated with this opportunity" />
</div>
{:else}
<div class="contacts-grid">
{#each contacts as c (c.id)}
<div class="contact-card">
<div class="contact-avatar">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
</div>
<div class="contact-info">
<span class="contact-name">{c.contact?.name ?? "Unknown"}</span>
{#if c.role?.name}
<span class="contact-role">{c.role.name}</span>
{/if}
{#if c.company?.name}
<span class="contact-company">{c.company.name}</span>
{/if}
{#if c.notes}
<span class="contact-notes">{c.notes}</span>
{/if}
</div>
{#if c.referralFlag}
<span class="contact-badge referral">Referral</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,394 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowActionPayload,
WorkflowStatusKey,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let statusKey: WorkflowStatusKey | null = null;
export let hasWon = false;
export let hasLost = false;
export let canFinalize = false;
export let initialOutcome: "won" | "lost" | null = null;
export let error: string | null = null;
export let isSubmitting = false;
// If user has finalize permission, show checkbox (checked by default)
// If not, the action will send to Pending Won/Lost instead
let finalizeImmediately = canFinalize;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let noteError = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
let selectedOutcome: "won" | "lost" | null = initialOutcome;
// Auto-select based on initialOutcome, current pending status, or single-outcome availability
$: if (initialOutcome && selectedOutcome === null) {
selectedOutcome = initialOutcome;
} else if (statusKey === "PendingWon" && hasWon && selectedOutcome === null) {
selectedOutcome = "won";
} else if (
statusKey === "PendingLost" &&
hasLost &&
selectedOutcome === null
) {
selectedOutcome = "lost";
} else if (hasWon && !hasLost && selectedOutcome === null) {
selectedOutcome = "won";
} else if (hasLost && !hasWon && selectedOutcome === null) {
selectedOutcome = "lost";
}
function validate(): boolean {
noteError = "";
if (!note.trim()) {
noteError = "A note is required to finalize.";
return false;
}
if (!selectedOutcome) {
return false;
}
return true;
}
function handleSubmit() {
if (!validate()) return;
const payload: WorkflowActionPayload = {
outcome: selectedOutcome!,
finalize: canFinalize && finalizeImmediately,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
noteError = "";
selectedOutcome = null;
finalizeImmediately = canFinalize;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
$: pendingOnly = !canFinalize || !finalizeImmediately;
$: title = pendingOnly
? selectedOutcome === "won"
? "Mark as Pending Won"
: selectedOutcome === "lost"
? "Mark as Pending Lost"
: "Mark Outcome"
: selectedOutcome === "won"
? "Finalize as Won"
: selectedOutcome === "lost"
? "Finalize as Lost"
: "Finalize Opportunity";
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal" on:click|stopPropagation>
<div
class="wf-modal-header"
class:wf-header-won={selectedOutcome === "won"}
class:wf-header-lost={selectedOutcome === "lost"}
>
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Outcome selector (if both are available) -->
{#if hasWon && hasLost}
<div
class="wf-finalize-selector"
class:has-selection={selectedOutcome !== null}
class:lost-selected={selectedOutcome === "lost"}
>
<button
class="wf-finalize-option wf-finalize-won"
class:wf-selected={selectedOutcome === "won"}
on:click={() => (selectedOutcome = "won")}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline
points="22 4 12 14.01 9 11.01"
/>
</svg>
<span>Won</span>
</button>
<button
class="wf-finalize-option wf-finalize-lost"
class:wf-selected={selectedOutcome === "lost"}
on:click={() => (selectedOutcome = "lost")}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>Lost</span>
</button>
</div>
{/if}
<!-- Finalize checkbox (only if user has finalize permission) -->
{#if canFinalize}
<label class="wf-finalize-check">
<input type="checkbox" bind:checked={finalizeImmediately} />
<span>Finalize immediately</span>
<span
class="wf-info-icon"
title="Skip the pending approval step and finalize directly to Won or Lost."
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="16"
x2="12"
y2="12"
/><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
</label>
{/if}
<!-- Pending disclaimer -->
{#if pendingOnly}
<div class="wf-pending-notice">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="16"
x2="12"
y2="12"
/><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span
>This will set the opportunity to <strong
>Pending {selectedOutcome === "won"
? "Won"
: selectedOutcome === "lost"
? "Lost"
: "Won/Lost"}</strong
> for manager reviewal before finalizing.</span
>
</div>
{/if}
<!-- Note field (required) -->
<div class="wf-field-group">
<label class="wf-field-label" for="fin-note">
Note <span class="wf-required">*</span>
</label>
<textarea
id="fin-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder="Explain the finalization..."
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="fin-time-start">Start</label>
<input
id="fin-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="fin-time-end">End</label>
<input
id="fin-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
</div>
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn {selectedOutcome === 'won'
? 'wf-btn-success'
: 'wf-btn-danger'}"
on:click={handleSubmit}
disabled={isSubmitting || !selectedOutcome}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,88 @@
<script lang="ts">
import type { OpportunityForecast } from "../types";
import { formatCurrency, formatDate } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let forecasts: OpportunityForecast[];
</script>
<div class="forecasts-tab">
{#if forecasts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No forecast data available" />
</div>
{:else}
<div class="forecasts-table-wrap">
<table class="forecasts-table">
<thead>
<tr>
<th>Type</th>
<th>Month</th>
<th class="num">Revenue</th>
<th class="num">Cost</th>
<th class="num">Margin</th>
<th class="num">Probability</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each forecasts as f (f.id)}
<tr>
<td>{f.forecastType ?? "—"}</td>
<td>{formatDate(f.forecastMonth)}</td>
<td class="num">{formatCurrency(f.revenue)}</td>
<td class="num">{formatCurrency(f.cost)}</td>
<td class="num">
{f.revenue != null && f.cost != null
? formatCurrency(f.revenue - f.cost)
: "—"}
</td>
<td class="num">
{f.forecastPercentage != null
? `${f.forecastPercentage}%`
: "—"}
</td>
<td>
<span
class="forecast-status-badge"
class:included={f.includedFlag}
>
{f.status?.name ?? "—"}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Revenue summary -->
<div class="forecasts-summary">
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Revenue</span>
<span class="forecast-summary-value">
{formatCurrency(
forecasts.reduce((sum, f) => sum + (f.revenue ?? 0), 0),
)}
</span>
</div>
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Cost</span>
<span class="forecast-summary-value">
{formatCurrency(forecasts.reduce((sum, f) => sum + (f.cost ?? 0), 0))}
</span>
</div>
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Margin</span>
<span class="forecast-summary-value">
{formatCurrency(
forecasts.reduce(
(sum, f) => sum + ((f.revenue ?? 0) - (f.cost ?? 0)),
0,
),
)}
</span>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,520 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { OpportunityNote } from "../types";
import { noteAuthorInitials, formatDateTime } from "../types";
import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
import { clientFetch } from "$lib/client-fetch";
export let notes: OpportunityNote[] = [];
export let permissions: PermissionMap = {} as PermissionMap;
export let opportunityId: string;
export let isClosedOpportunity: boolean = false;
const dispatch = createEventDispatcher();
$: 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;
let composeText = "";
let composeFlagged = false;
let composeSaving = false;
let composeError = "";
// ── Edit state ──
let editingNoteId: number | null = null;
let editText = "";
let editFlagged = false;
let editSaving = false;
let editError = "";
// ── Delete state ──
let deletingNoteId: number | null = null;
let deleteLoading = false;
let deleteError = "";
// ── Menu state ──
let openMenuId: number | null = null;
function toggleMenu(id: number) {
openMenuId = openMenuId === id ? null : id;
}
function handleMenuClickOutside(e: MouseEvent) {
if (openMenuId === null) return;
const target = e.target as HTMLElement;
if (target.closest(".note-menu-wrap")) return;
openMenuId = null;
}
// ── Compose ──
function startCompose() {
composing = true;
composeText = "";
composeFlagged = false;
composeError = "";
}
function cancelCompose() {
composing = false;
composeText = "";
composeFlagged = false;
composeError = "";
}
async function submitNote() {
if (!composeText.trim()) return;
composeSaving = true;
composeError = "";
try {
await clientFetch(`/sales/opportunity/${opportunityId}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: composeText.trim(),
flagged: composeFlagged,
}),
});
composing = false;
composeText = "";
composeFlagged = false;
dispatch("notesChanged");
} catch (err: unknown) {
composeError =
err instanceof Error ? err.message : "Failed to create note";
} finally {
composeSaving = false;
}
}
// ── Edit ──
function startEdit(note: OpportunityNote) {
editingNoteId = note.id;
editText = note.text ?? "";
editFlagged = note.flagged ?? false;
editError = "";
openMenuId = null;
}
function cancelEdit() {
editingNoteId = null;
editText = "";
editFlagged = false;
editError = "";
}
async function submitEdit() {
if (editingNoteId === null || !editText.trim()) return;
editSaving = true;
editError = "";
try {
await clientFetch(
`/sales/opportunity/${opportunityId}/notes/${editingNoteId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: editText.trim(), flagged: editFlagged }),
},
);
editingNoteId = null;
editText = "";
editFlagged = false;
dispatch("notesChanged");
} catch (err: unknown) {
editError = err instanceof Error ? err.message : "Failed to update note";
} finally {
editSaving = false;
}
}
// ── Delete ──
function confirmDelete(noteId: number) {
deletingNoteId = noteId;
deleteError = "";
openMenuId = null;
}
function cancelDelete() {
deletingNoteId = null;
deleteError = "";
}
async function executeDelete() {
if (deletingNoteId === null) return;
deleteLoading = true;
deleteError = "";
try {
await clientFetch(
`/sales/opportunity/${opportunityId}/notes/${deletingNoteId}`,
{ method: "DELETE" },
);
deletingNoteId = null;
dispatch("notesChanged");
} catch (err: unknown) {
deleteError =
err instanceof Error ? err.message : "Failed to delete note";
} finally {
deleteLoading = false;
}
}
function handleComposeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitNote();
}
if (e.key === "Escape") {
cancelCompose();
}
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitEdit();
}
if (e.key === "Escape") {
cancelEdit();
}
}
</script>
<svelte:window on:click={handleMenuClickOutside} />
<div class="notes-tab">
<!-- Header bar -->
<div class="notes-header">
<div class="notes-header-left">
<span class="notes-count">
{notes.length} note{notes.length === 1 ? "" : "s"}
</span>
</div>
{#if !composing}
<button
class="notes-add-btn"
on:click={startCompose}
type="button"
disabled={!canCreate}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Note
</button>
{/if}
</div>
<!-- Compose area -->
{#if composing}
<div class="note-compose">
<textarea
class="note-compose-textarea"
placeholder="Write a note…"
bind:value={composeText}
on:keydown={handleComposeKeydown}
rows="3"
disabled={composeSaving}
></textarea>
<div class="note-compose-footer">
<label class="note-flag-toggle">
<input
type="checkbox"
bind:checked={composeFlagged}
disabled={composeSaving}
/>
<svg
viewBox="0 0 24 24"
fill={composeFlagged ? "currentColor" : "none"}
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class="flag-icon"
class:flagged={composeFlagged}
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
Flag
</label>
<div class="note-compose-actions">
{#if composeError}
<span class="note-error">{composeError}</span>
{/if}
<button
class="note-btn-cancel"
on:click={cancelCompose}
disabled={composeSaving}
type="button"
>
Cancel
</button>
<button
class="note-btn-save"
on:click={submitNote}
disabled={composeSaving || !composeText.trim()}
type="button"
>
{#if composeSaving}
Saving…
{:else}
Save
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Notes list -->
{#if notes.length === 0 && !composing}
<div class="tab-empty">
<NoResultsMonkey message="No notes yet" />
</div>
{:else}
<div class="notes-list">
{#each notes as note (note.id)}
{#if editingNoteId === note.id}
<!-- Inline edit -->
<div class="note-card editing">
<textarea
class="note-edit-textarea"
bind:value={editText}
on:keydown={handleEditKeydown}
rows="3"
disabled={editSaving}
></textarea>
<div class="note-compose-footer">
<label class="note-flag-toggle">
<input
type="checkbox"
bind:checked={editFlagged}
disabled={editSaving}
/>
<svg
viewBox="0 0 24 24"
fill={editFlagged ? "currentColor" : "none"}
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class="flag-icon"
class:flagged={editFlagged}
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
Flag
</label>
<div class="note-compose-actions">
{#if editError}
<span class="note-error">{editError}</span>
{/if}
<button
class="note-btn-cancel"
on:click={cancelEdit}
disabled={editSaving}
type="button"
>
Cancel
</button>
<button
class="note-btn-save"
on:click={submitEdit}
disabled={editSaving || !editText.trim()}
type="button"
>
{#if editSaving}
Saving…
{:else}
Update
{/if}
</button>
</div>
</div>
</div>
{:else}
<!-- Read-only note card -->
<div class="note-card" class:flagged={note.flagged}>
<div class="note-card-header">
<div class="note-header-left">
{#if canUpdate || canDelete}
<div class="note-menu-wrap">
<button
class="note-menu-btn"
on:click|stopPropagation={() => toggleMenu(note.id)}
type="button"
aria-label="Note actions"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="3" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13" r="1.5" />
</svg>
</button>
{#if openMenuId === note.id}
<div class="note-menu-dropdown">
{#if canUpdate}
<button
class="note-menu-item"
on:click={() => startEdit(note)}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canDelete}
<button
class="note-menu-item danger"
on:click={() => confirmDelete(note.id)}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
{#if note.type?.name}
<span class="note-type-badge">{note.type.name}</span>
{/if}
{#if note.flagged}
<svg
class="note-flag-icon"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
{/if}
</div>
<div class="note-header-right">
<div class="note-author-info">
<span class="note-author-name"
>{note.enteredBy?.name ?? "Unknown"}</span
>
<span class="note-timestamp"
>{formatDateTime(note.dateEntered)}</span
>
</div>
<div
class="note-avatar"
title={note.enteredBy?.name ?? "Unknown"}
>
{noteAuthorInitials(note.enteredBy?.name)}
</div>
</div>
</div>
<div class="note-card-body">
<p class="note-text">{note.text ?? ""}</p>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Delete confirmation modal -->
{#if deletingNoteId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="note-delete-overlay" on:click={cancelDelete} role="presentation">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="note-delete-modal"
on:click|stopPropagation
role="dialog"
aria-modal="true"
tabindex="-1"
>
<h4 class="note-delete-title">Delete Note</h4>
<p class="note-delete-msg">
Are you sure you want to delete this note? This action cannot be undone.
</p>
{#if deleteError}
<p class="note-error">{deleteError}</p>
{/if}
<div class="note-delete-actions">
<button
class="note-btn-cancel"
on:click={cancelDelete}
disabled={deleteLoading}
type="button"
>
Cancel
</button>
<button
class="note-btn-delete"
on:click={executeDelete}
disabled={deleteLoading}
type="button"
>
{#if deleteLoading}
Deleting…
{:else}
Delete
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,331 @@
<script lang="ts">
import { clientFetch } from "$lib/client-fetch";
import type { WorkflowStatusResponse } from "../types";
import {
WORKFLOW_STATUS_LABELS,
STATUS_ID_TO_KEY,
} from "$lib/optima-api/modules/sales";
import type {
SalesOpportunity,
WorkflowHistoryEntry,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import { formatDateTime } from "../types";
export let opportunity: SalesOpportunity | null;
export let workflowStatus: WorkflowStatusResponse | null;
export let opportunityId: string;
let historyEntries: WorkflowHistoryEntry[] = [];
let isLoading = true;
let loadError: string | null = null;
// Fetch workflow history on mount
import { onMount } from "svelte";
onMount(async () => {
await fetchHistory();
});
async function fetchHistory() {
isLoading = true;
loadError = null;
try {
const json = await clientFetch<{
data: { activities: WorkflowHistoryEntry[] };
}>(`/sales/opportunity/${opportunityId}/workflow/history`);
historyEntries = json.data?.activities ?? [];
} catch (err) {
loadError = err instanceof Error ? err.message : "Failed to load history";
} finally {
isLoading = false;
}
}
// Derive stats from history
$: totalActions = historyEntries.length;
$: uniqueTypes = (() => {
const types = new Set<string>();
for (const e of historyEntries) {
if (e.optimaType) types.add(e.optimaType);
}
return types;
})();
$: currentStatusLabel = (() => {
if (!workflowStatus) return opportunity?.status?.name ?? "Unknown";
const key = STATUS_ID_TO_KEY[workflowStatus.currentStatusId];
return key ? WORKFLOW_STATUS_LABELS[key] : workflowStatus.currentStatus;
})();
$: isCold = workflowStatus?.coldCheck?.isCold ?? false;
$: staleDays = workflowStatus?.coldCheck?.daysSinceActivity ?? null;
</script>
<div class="opp-report-tab">
<div class="opp-report-header">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Opportunity Report
</h3>
</div>
<!-- Summary Cards -->
<div class="opp-report-cards">
<div class="opp-report-card">
<span class="opp-report-card-label">Current Status</span>
<span class="opp-report-card-value">{currentStatusLabel}</span>
</div>
<div class="opp-report-card">
<span class="opp-report-card-label">Workflow Actions</span>
<span class="opp-report-card-value">{totalActions}</span>
</div>
<div class="opp-report-card">
<span class="opp-report-card-label">Activity Types</span>
<span class="opp-report-card-value">{uniqueTypes.size}</span>
</div>
{#if staleDays !== null}
<div class="opp-report-card" class:opp-report-card-warning={isCold}>
<span class="opp-report-card-label">Days Since Activity</span>
<span class="opp-report-card-value">{staleDays}</span>
</div>
{/if}
</div>
<!-- History Table -->
<div class="opp-report-section">
<h4 class="opp-report-section-title">Workflow History</h4>
{#if isLoading}
<div class="opp-report-loading">
<span class="wf-spinner"></span>
Loading history...
</div>
{:else if loadError}
<div class="opp-report-error">{loadError}</div>
{:else if historyEntries.length === 0}
<p class="opp-report-empty">No workflow history for this opportunity.</p>
{:else}
<div class="opp-report-table-wrap">
<table class="opp-report-table">
<thead>
<tr>
<th>Type</th>
<th>Activity</th>
<th>Assigned To</th>
<th>Started</th>
<th>Closed</th>
</tr>
</thead>
<tbody>
{#each historyEntries as entry, i (entry.activity.cwActivityId ?? i)}
{@const act = entry.activity}
<tr>
<td>
{#if entry.optimaType}
<span class="opp-report-type-badge">{entry.optimaType}</span
>
{:else}
<span class="opp-report-type-muted"></span>
{/if}
</td>
<td>
{#if act.name}
<span class="opp-report-activity-name">{act.name}</span>
{/if}
{#if act.notes}
<span class="opp-report-activity-notes">{act.notes}</span>
{/if}
</td>
<td>{act.assignTo?.name ?? act.cwEnteredBy ?? "System"}</td>
<td>{formatDateTime(act.cwDateEntered ?? act.dateStart)}</td>
<td>
{#if act.dateEnd}
{formatDateTime(act.dateEnd)}
{:else}
<span class="opp-report-open-badge">Open</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
<style>
.opp-report-tab {
padding: 0;
}
.opp-report-header {
margin-bottom: 16px;
}
.opp-report-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.opp-report-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
border-radius: 8px;
background: var(--bg-inset);
border: 1px solid var(--card-border);
}
.opp-report-card.opp-report-card-warning {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.04);
}
.opp-report-card-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.opp-report-card-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.opp-report-section {
margin-bottom: 20px;
}
.opp-report-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 12px;
}
.opp-report-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 0;
color: var(--text-muted);
font-size: 13px;
}
.opp-report-error {
padding: 10px;
border-radius: 6px;
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.15);
color: #dc2626;
font-size: 12px;
}
.opp-report-empty {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
margin: 0;
}
.opp-report-table-wrap {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--card-border);
}
.opp-report-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.opp-report-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-inset);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
.opp-report-table td {
padding: 8px 12px;
vertical-align: top;
border-bottom: 1px solid var(--card-border);
color: var(--text-primary);
}
.opp-report-table tr:last-child td {
border-bottom: none;
}
.opp-report-type-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(59, 130, 246, 0.08);
color: #2563eb;
}
.opp-report-type-muted {
color: var(--text-muted);
}
.opp-report-activity-name {
display: block;
font-weight: 500;
}
.opp-report-activity-notes {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.opp-report-open-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
@media (max-width: 768px) {
.opp-report-cards {
grid-template-columns: 1fr 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,755 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type {
SalesOpportunity,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import type {
OpportunityNote,
OpportunityContact,
OpportunityProduct,
} from "../types";
import {
formatDate,
formatDateTime,
formatCurrency,
statusColorClass,
} from "../types";
const dispatch = createEventDispatcher<{
selectProduct: number;
switchTab: string;
}>();
export let opportunity: SalesOpportunity | null;
export let notes: OpportunityNote[];
export let contacts: OpportunityContact[];
export let products: OpportunityProduct[];
export let permissions: PermissionMap = {};
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
// ── Active (non-cancelled) products ──
$: activeProducts = products.filter((p) => p.cancellationType !== "full");
// ── Cancellation stats ──
$: fullyCancelled = products.filter((p) => p.cancellationType === "full");
$: partiallyCancelled = products.filter(
(p) => p.cancellationType === "partial",
);
$: hasCancellations =
fullyCancelled.length > 0 || partiallyCancelled.length > 0;
// ── Financial KPIs from products ──
$: totalRevenue = activeProducts.reduce((s, p) => s + (p.revenue ?? 0), 0);
$: totalCost = activeProducts.reduce((s, p) => s + (p.cost ?? 0), 0);
$: totalMargin = totalRevenue - totalCost;
$: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: taxRate = (opportunity?.expectedSalesTaxRate ?? 0) / 100;
$: taxableRevenue = activeProducts
.filter((p) => p.taxableFlag)
.reduce((s, p) => s + (p.revenue ?? 0), 0);
$: totalTax =
opportunity?.totalSalesTax != null
? opportunity.totalSalesTax
: taxRate > 0
? taxableRevenue * taxRate
: 0;
$: grandTotal = totalRevenue + totalTax;
// ── Product class breakdown ──
$: classBreakdown = (() => {
const map = new Map<
string,
{ revenue: number; cost: number; count: number }
>();
for (const p of activeProducts) {
const cls = p.productClass || "Other";
const entry = map.get(cls) ?? { revenue: 0, cost: 0, count: 0 };
entry.revenue += p.revenue ?? 0;
entry.cost += p.cost ?? 0;
entry.count++;
map.set(cls, entry);
}
return [...map.entries()]
.sort((a, b) => b[1].revenue - a[1].revenue)
.map(([name, data]) => ({
name,
...data,
margin: data.revenue - data.cost,
marginPct:
data.cost > 0 ? ((data.revenue - data.cost) / data.cost) * 100 : 0,
}));
})();
// ── Top products by revenue (up to 15), active first then cancelled ──
$: topProducts = (() => {
const active = [...activeProducts]
.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0))
.slice(0, 15);
const cancelled = [...fullyCancelled]
.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0))
.slice(0, Math.max(0, 15 - active.length));
return [...active, ...cancelled];
})();
/** Effective quantity after cancellations */
function effectiveQty(p: OpportunityProduct): number {
return (p.quantity ?? 0) - (p.quantityCancelled ?? 0);
}
// ── Timeline entries — milestones + workflow activities, sorted chronologically ──
type TimelineEntry = {
label: string;
date: string;
kind: "milestone" | "activity";
dotClass?: string;
textClass?: string;
highlight?: boolean;
};
// Collapse threshold: show first N and last N when total > MAX
const TIMELINE_MAX = 8;
const TIMELINE_EDGE = 4;
$: timelineCollapsed = timeline.length > TIMELINE_MAX;
$: visibleTimeline = timelineCollapsed
? [...timeline.slice(0, TIMELINE_EDGE), ...timeline.slice(-TIMELINE_EDGE)]
: timeline;
$: hiddenCount = timelineCollapsed ? timeline.length - TIMELINE_EDGE * 2 : 0;
function getOptimaType(activity: OpportunityActivity): string | null {
const cf = activity.customFields?.find(
(f) => f.id === 45 || f.caption === "Optima_Type",
);
return cf?.value ?? null;
}
function activityDotClass(type: string | null): string {
const map: Record<string, string> = {
"Opportunity Created": "ov-dot-created",
"Opportunity Setup": "ov-dot-setup",
"Opportunity Review": "ov-dot-review",
"Quote Sent": "ov-dot-sent",
"Quote Confirmed": "ov-dot-confirmed",
"Quote Sent & Confirmed": "ov-dot-sent-confirmed",
Revision: "ov-dot-revision",
Finalized: "ov-dot-finalized",
Converted: "ov-dot-converted",
"Quote Generated": "ov-dot-generated",
};
return map[type ?? ""] ?? "ov-dot-default";
}
function activityTextClass(type: string | null): string {
const map: Record<string, string> = {
"Opportunity Created": "ov-text-created",
"Opportunity Setup": "ov-text-setup",
"Opportunity Review": "ov-text-review",
"Quote Sent": "ov-text-sent",
"Quote Confirmed": "ov-text-confirmed",
"Quote Sent & Confirmed": "ov-text-sent-confirmed",
Revision: "ov-text-revision",
Finalized: "ov-text-finalized",
Converted: "ov-text-converted",
"Quote Generated": "ov-text-generated",
};
return map[type ?? ""] ?? "";
}
$: timeline = (() => {
const entries: TimelineEntry[] = [];
// Milestones from opportunity dates
if (opportunity?.createdAt)
entries.push({
label: "Created",
date: opportunity.createdAt,
kind: "milestone",
});
if (opportunity?.dateBecameLead)
entries.push({
label: "Became Lead",
date: opportunity.dateBecameLead,
kind: "milestone",
});
if (opportunity?.pipelineChangeDate)
entries.push({
label: "Pipeline Changed",
date: opportunity.pipelineChangeDate,
kind: "milestone",
});
if (opportunity?.expectedCloseDate)
entries.push({
label: "Expected Close",
date: opportunity.expectedCloseDate,
kind: "milestone",
highlight: true,
});
if (opportunity?.closedDate)
entries.push({
label: "Closed",
date: opportunity.closedDate,
kind: "milestone",
});
// Workflow activities
for (const a of opportunity?.activities ?? []) {
const optimaType = getOptimaType(a);
if (!optimaType) continue;
const date = a.cwDateEntered ?? a.dateStart ?? "";
if (!date) continue;
entries.push({
label: optimaType,
date,
kind: "activity",
dotClass: activityDotClass(optimaType),
textClass: activityTextClass(optimaType),
});
}
// Sort chronologically (oldest first)
return entries.sort((a, b) => a.date.localeCompare(b.date));
})();
$: 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")
);
})();
// Days until expected close
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
const raw = opportunity.expectedCloseDate;
const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
const now = new Date();
const closeDay = new Date(
close.getFullYear(),
close.getMonth(),
close.getDate(),
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diff = Math.round(
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
return diff;
})();
// Age in days
$: ageDays = (() => {
if (!opportunity?.createdAt) return null;
return Math.floor(
(Date.now() - new Date(opportunity.createdAt).getTime()) /
(1000 * 60 * 60 * 24),
);
})();
function marginHealthColor(pct: number): string {
if (pct >= 30) return "healthy";
if (pct >= 15) return "moderate";
if (pct >= 0) return "low";
return "negative";
}
function shortCurrency(amount: number): string {
if (Math.abs(amount) >= 1_000_000) {
return `$${(amount / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(amount) >= 1_000) {
return `$${(amount / 1_000).toFixed(1)}K`;
}
return formatCurrency(amount);
}
// ── Product popover state ──
let hoveredProduct: OpportunityProduct | null = null;
let popoverX = 0;
let popoverY = 0;
let popoverTimeout: ReturnType<typeof setTimeout> | null = null;
function showPopover(e: MouseEvent, product: OpportunityProduct) {
if (popoverTimeout) clearTimeout(popoverTimeout);
hoveredProduct = product;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
popoverX = rect.left;
popoverY = rect.top - 4;
}
function hidePopover() {
popoverTimeout = setTimeout(() => {
hoveredProduct = null;
}, 150);
}
function keepPopover() {
if (popoverTimeout) clearTimeout(popoverTimeout);
}
function productMarginPct(p: OpportunityProduct): string {
if (!p.cost || p.cost === 0) return "—";
return ((((p.revenue ?? 0) - p.cost) / p.cost) * 100).toFixed(1) + "%";
}
</script>
<div class="overview-tab">
<!-- ═══ Pipeline Banner ═══ -->
<div class="ov-pipeline-banner">
<div class="ov-pipeline-stages"></div>
</div>
<!-- ═══ Financial KPI Strip ═══ -->
<div class="ov-kpi-strip">
<div class="ov-kpi-card primary">
<span class="ov-kpi-label">Revenue</span>
<span class="ov-kpi-value">{formatCurrency(totalRevenue)}</span>
<span class="ov-kpi-sub"
>{activeProducts.length} line item{activeProducts.length !== 1
? "s"
: ""}</span
>
</div>
{#if canViewCost}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Cost</span>
<span class="ov-kpi-value">{formatCurrency(totalCost)}</span>
</div>
{/if}
{#if canViewMargin}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Margin</span>
<span class="ov-kpi-value {marginHealthColor(marginPct)}"
>{formatCurrency(totalMargin)}</span
>
<span class="ov-kpi-pct {marginHealthColor(marginPct)}"
>{marginPct.toFixed(0)}%</span
>
</div>
{/if}
{#if totalTax > 0}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Sales Tax</span>
<span class="ov-kpi-value">{formatCurrency(totalTax)}</span>
</div>
{/if}
<div class="ov-kpi-card accent">
<span class="ov-kpi-label">Total</span>
<span class="ov-kpi-value">{formatCurrency(grandTotal)}</span>
</div>
{#if hasCancellations}
<div class="ov-kpi-card cancelled-kpi">
<span class="ov-kpi-label">Cancelled</span>
<span class="ov-kpi-value">
{fullyCancelled.length + partiallyCancelled.length}
</span>
<span class="ov-kpi-sub">
{#if fullyCancelled.length > 0}{fullyCancelled.length} full{/if}{#if fullyCancelled.length > 0 && partiallyCancelled.length > 0},
{/if}{#if partiallyCancelled.length > 0}{partiallyCancelled.length} partial{/if}
</span>
</div>
{/if}
</div>
<!-- ═══ Two-column layout: Timeline + Forecast ═══ -->
<div class="ov-main-grid">
<!-- Left: Timeline -->
<div class="ov-section ov-timeline-section">
<h3 class="ov-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/>
</svg>
Timeline
{#if ageDays !== null}
<span class="ov-age-badge">Age: {ageDays}d</span>
{/if}
</h3>
{#if timeline.length > 0}
<div class="ov-timeline">
{#each visibleTimeline as entry, i}
{#if timelineCollapsed && i === TIMELINE_EDGE}
<!-- Collapsed gap -->
<button
class="ov-timeline-gap"
on:click={() => dispatch("switchTab", "Activity")}
title="View all in Activity tab"
>
<span class="ov-gap-pill">{hiddenCount} more events</span>
<svg
class="ov-gap-arrow"
viewBox="0 0 16 16"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M6 3l5 5-5 5" /></svg
>
</button>
{/if}
<div
class="ov-timeline-item"
class:last={i === visibleTimeline.length - 1}
class:highlight={entry.highlight}
class:ov-activity-item={entry.kind === "activity"}
>
<div
class="ov-timeline-dot {entry.kind === 'activity'
? (entry.dotClass ?? '')
: ''}"
class:highlight={entry.highlight}
></div>
<div class="ov-timeline-content">
<span
class="ov-timeline-label {entry.kind === 'activity'
? (entry.textClass ?? '')
: ''}">{entry.label}</span
>
<span class="ov-timeline-date"
>{entry.kind === "activity"
? formatDateTime(entry.date)
: formatDate(entry.date)}</span
>
</div>
</div>
{/each}
</div>
{:else}
<p class="ov-empty-note">No timeline events yet.</p>
{/if}
<!-- Quick Stats under Timeline -->
<div class="ov-quick-stats">
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/><polyline points="14 2 14 8 20 8" />
</svg>
<span>{notes.length} Note{notes.length !== 1 ? "s" : ""}</span>
</div>
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/>
</svg>
<span
>{contacts.length} Contact{contacts.length !== 1 ? "s" : ""}</span
>
</div>
{#if opportunity?.source}
<div class="ov-quick-stat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
<span>{opportunity.source}</span>
</div>
{/if}
</div>
</div>
<!-- Right: Condensed Forecast -->
<div class="ov-section ov-forecast-section">
<h3 class="ov-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path
d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"
/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line
x1="12"
y1="22.08"
x2="12"
y2="12"
/>
</svg>
Forecast Summary
</h3>
{#if topProducts.length > 0}
<!-- Top Products mini-table -->
<div class="ov-forecast-table-wrap">
<table class="ov-forecast-table">
<thead>
<tr>
<th class="col-product">Product</th>
<th class="col-revenue">Revenue</th>
{#if canViewMargin}
<th class="col-margin">Margin</th>
{/if}
</tr>
</thead>
<tbody>
{#each topProducts as p}
<tr
class:ov-row-cancelled-full={p.cancellationType === "full"}
class:ov-row-cancelled-partial={p.cancellationType ===
"partial"}
on:mouseenter={(e) => showPopover(e, p)}
on:mouseleave={hidePopover}
on:click={() => dispatch("selectProduct", p.id)}
class="ov-forecast-row-clickable"
>
<td class="col-product">
<span class="ov-product-inline">
<span
class="ov-qty-badge"
class:partial={p.cancellationType === "partial"}
class:cancelled={p.cancellationType === "full"}
>
{#if p.cancellationType === "partial"}
{effectiveQty(p)}<span class="ov-qty-orig"
>/{p.quantity}</span
>
{:else}
{p.quantity ?? "—"}
{/if}
</span>
<span class="ov-product-id"
>{p.catalogItem?.identifier ?? "—"}</span
>
{#if p.productDescription}
<span class="ov-product-desc"
>{p.productDescription}</span
>
{/if}
{#if p.cancellationType === "full"}
<span class="ov-cancel-badge full">Cancelled</span>
{:else if p.cancellationType === "partial"}
<span class="ov-cancel-badge partial">Partial</span>
{/if}
</span>
</td>
<td class="col-revenue">{formatCurrency(p.revenue)}</td>
{#if canViewMargin}
<td class="col-margin">
{#if p.cost && p.cost > 0}
<span
class="ov-margin-badge {marginHealthColor(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) * 100,
)}"
>
{(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) *
100
).toFixed(0)}%
</span>
{:else}
<span class="ov-margin-badge neutral"></span>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td class="col-product"><strong>Subtotal</strong></td>
<td class="col-revenue"
><strong>{formatCurrency(totalRevenue)}</strong></td
>
{#if canViewMargin}
<td class="col-margin">
<span
class="ov-margin-badge {marginHealthColor(marginPct)}"
>
{marginPct.toFixed(0)}%
</span>
</td>
{/if}
</tr>
</tfoot>
</table>
<!-- Product hover popover -->
{#if hoveredProduct}
<div
class="ov-product-popover"
style="top: {popoverY}px; left: {popoverX}px;"
on:mouseenter={keepPopover}
on:mouseleave={hidePopover}
role="tooltip"
>
<div class="ov-popover-header">
<span class="ov-popover-id"
>{hoveredProduct.catalogItem?.identifier ?? "—"}</span
>
</div>
{#if hoveredProduct.productDescription}
<div class="ov-popover-desc">
{hoveredProduct.productDescription}
</div>
{/if}
{#if hoveredProduct.forecastDescription && hoveredProduct.forecastDescription !== hoveredProduct.productDescription}
<div class="ov-popover-field">
<span class="ov-popover-label">Forecast</span>
<span class="ov-popover-value"
>{hoveredProduct.forecastDescription}</span
>
</div>
{/if}
{#if hoveredProduct.forecastType}
<div class="ov-popover-field">
<span class="ov-popover-label">Type</span>
<span class="ov-popover-value"
>{hoveredProduct.forecastType}</span
>
</div>
{/if}
{#if hoveredProduct.productClass}
<div class="ov-popover-field">
<span class="ov-popover-label">Class</span>
<span class="ov-popover-value"
>{hoveredProduct.productClass}</span
>
</div>
{/if}
<div class="ov-popover-financials">
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Qty</span>
<span class="ov-popover-fin-value"
>{hoveredProduct.quantity ?? "—"}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Revenue</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.revenue)}</span
>
</div>
{#if canViewCost}
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Cost</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.cost)}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.margin)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin %</span>
<span class="ov-popover-fin-value"
>{productMarginPct(hoveredProduct)}</span
>
</div>
{/if}
</div>
{#if hoveredProduct.cancellationType}
<div
class="ov-popover-cancel {hoveredProduct.cancellationType ===
'full'
? 'ov-popover-cancel--full'
: ''}"
>
{hoveredProduct.cancellationType === "partial"
? "Partially Cancelled"
: "Cancelled"}
{#if hoveredProduct.quantityCancelled}
{hoveredProduct.quantityCancelled} unit{hoveredProduct.quantityCancelled !==
1
? "s"
: ""}
{/if}
</div>
{/if}
{#if hoveredProduct.recurringFlag}
<div class="ov-popover-flag">Recurring</div>
{/if}
</div>
{/if}
{#if activeProducts.length > 15}
<div class="ov-forecast-more">
+{activeProducts.length - 15} more item{activeProducts.length -
15 !==
1
? "s"
: ""}
</div>
{/if}
</div>
<!-- Class Breakdown -->
{#if classBreakdown.length > 1}
<div class="ov-class-breakdown">
<span class="ov-class-breakdown-title">By Product Class</span>
<div class="ov-class-bars">
{#each classBreakdown as cls}
<div class="ov-class-row">
<span class="ov-class-name">{cls.name}</span>
<div class="ov-class-bar-track">
<div
class="ov-class-bar-fill"
style="width: {totalRevenue > 0
? (cls.revenue / totalRevenue) * 100
: 0}%"
></div>
</div>
<span class="ov-class-amount"
>{shortCurrency(cls.revenue)}</span
>
</div>
{/each}
</div>
</div>
{/if}
{:else}
<p class="ov-empty-note">No products on this opportunity yet.</p>
{/if}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
ReviewDecision,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
sendQuote: void;
close: void;
}>();
export let isOpen = false;
export let canCancel = false;
export let canSend = true;
export let error: string | null = null;
export let isSubmitting = false;
let selectedDecision: ReviewDecision | null = null;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let noteError = "";
let showSendQuoteConfirm = false;
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
const decisions: {
key: ReviewDecision;
label: string;
desc: string;
class: string;
}[] = [
{
key: "approve",
label: "Approve",
desc: "Approve and move to Pending Sent",
class: "wf-decision-approve",
},
{
key: "reject",
label: "Reject",
desc: "Send back for revision",
class: "wf-decision-reject",
},
{
key: "send",
label: "Send Quote",
desc: "Approve and send quote immediately",
class: "wf-decision-send",
},
{
key: "cancel",
label: "Cancel",
desc: "Cancel the opportunity",
class: "wf-decision-cancel",
},
];
function selectDecision(d: ReviewDecision) {
if (d === "cancel" && !canCancel) return;
if (d === "send" && !canSend) return;
selectedDecision = d;
noteError = "";
showSendQuoteConfirm = false;
}
function validate(): boolean {
noteError = "";
if (!note.trim()) {
noteError = "A note is required for review decisions.";
return false;
}
return true;
}
function handleSubmit() {
if (!selectedDecision) return;
if (!validate()) return;
// If "send" was chosen, confirm then transition to SendQuote modal
if (selectedDecision === "send") {
showSendQuoteConfirm = true;
return;
}
const payload: WorkflowActionPayload = {
decision: selectedDecision,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleSendQuoteConfirm() {
// First submit the review decision with "send", then open SendQuote modal
const payload: WorkflowActionPayload = {
decision: "send" as ReviewDecision,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
selectedDecision = null;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
noteError = "";
showSendQuoteConfirm = false;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Finish Review"
tabindex="-1"
>
<div class="wf-modal wf-modal-wide" on:click|stopPropagation>
<div class="wf-modal-header wf-header-accent">
<h3 class="wf-modal-title">Finish Review</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
{#if showSendQuoteConfirm}
<!-- Send Quote confirmation step -->
<div class="wf-confirmation-box">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
<p>
This will approve the review and send the quote. The send quote
form will allow you to set additional options.
</p>
<div class="wf-confirmation-actions">
<button
class="wf-btn wf-btn-secondary"
on:click={() => (showSendQuoteConfirm = false)}
disabled={isSubmitting}
>
Go Back
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSendQuoteConfirm}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Continue to Send Quote
{/if}
</button>
</div>
</div>
{:else}
<!-- Decision selector -->
<div class="wf-decision-group">
<span class="wf-field-label">Decision</span>
<div class="wf-decision-buttons">
{#each decisions as d (d.key)}
{@const isDisabled =
(d.key === "cancel" && !canCancel) ||
(d.key === "send" && !canSend)}
<button
class="wf-decision-btn {d.class}"
class:wf-selected={selectedDecision === d.key}
class:wf-disabled-perm={isDisabled}
on:click={() => selectDecision(d.key)}
disabled={isSubmitting || isDisabled}
title={isDisabled
? d.key === "cancel"
? "You don't have permission to cancel opportunities"
: "You don't have permission to send quotes"
: d.desc}
>
{d.label}
</button>
{/each}
</div>
{#if selectedDecision}
<span class="wf-decision-desc">
{decisions.find((d) => d.key === selectedDecision)?.desc ?? ""}
</span>
{/if}
</div>
<!-- Note field (always required for review) -->
<div class="wf-field-group">
<label class="wf-field-label" for="rd-note">
Note <span class="wf-required">*</span>
</label>
<textarea
id="rd-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder="Explain the review decision..."
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="rd-time-start">Start</label
>
<input
id="rd-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="rd-time-end">End</label>
<input
id="rd-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if !showSendQuoteConfirm}
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSubmit}
disabled={isSubmitting || !selectedDecision}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Submit Decision
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,377 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowAction,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let canFinalize = false;
export let error: string | null = null;
export let isSubmitting = false;
export let actionName: WorkflowAction = "sendQuote";
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
let quoteConfirmed = false;
// Sub-option: only one can be selected at a time
type SubOption = "won" | "lost" | "revision" | null;
let selectedSubOption: SubOption = null;
let finalizeImmediately = canFinalize;
// Reset finalize toggle when canFinalize changes
$: finalizeImmediately = canFinalize;
function toggleConfirmed() {
quoteConfirmed = !quoteConfirmed;
if (!quoteConfirmed) selectedSubOption = null;
}
function selectSub(opt: SubOption) {
if (!quoteConfirmed) return;
selectedSubOption = selectedSubOption === opt ? null : opt;
}
function handleSubmit() {
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
payload.quoteConfirmed = quoteConfirmed;
payload.won = selectedSubOption === "won";
payload.lost = selectedSubOption === "lost";
payload.needsRevision = selectedSubOption === "revision";
if (
(selectedSubOption === "won" || selectedSubOption === "lost") &&
canFinalize
) {
payload.finalize = finalizeImmediately;
}
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
quoteConfirmed = false;
selectedSubOption = null;
finalizeImmediately = canFinalize;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
$: title = actionName === "resendQuote" ? "Re-Send Quote" : "Send Quote";
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal wf-modal-wide" on:click|stopPropagation>
<div class="wf-modal-header">
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Note field -->
<div class="wf-field-group">
<label class="wf-field-label" for="sq-note">
Note <span class="wf-optional">(optional)</span>
</label>
<textarea
id="sq-note"
class="wf-textarea"
bind:value={note}
placeholder="Add a note about this quote..."
rows="3"
></textarea>
</div>
<!-- Quote outcome options -->
<div class="sq-outcome-section">
<button
class="sq-confirmed-toggle"
class:sq-active={quoteConfirmed}
on:click={toggleConfirmed}
disabled={isSubmitting}
type="button"
>
<span class="sq-toggle-check">
{#if quoteConfirmed}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="14"
height="14"><polyline points="20 6 9 17 4 12" /></svg
>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="14"
height="14"
opacity="0.3"><circle cx="12" cy="12" r="9" /></svg
>
{/if}
</span>
<span class="sq-toggle-label">Quote Confirmed</span>
<span class="sq-toggle-hint">Customer confirmed receipt</span>
</button>
{#if quoteConfirmed}
<div class="sq-sub-options" transition:slide={{ duration: 180 }}>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "won"}
on:click={() => selectSub("won")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline
points="22 4 12 14.01 9 11.01"
/></svg
>
<span class="sq-sub-label">Won</span>
<span class="sq-sub-desc">
{canFinalize ? "Finalize as won" : "Send to Pending Won"}
</span>
</button>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "lost"}
on:click={() => selectSub("lost")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" /></svg
>
<span class="sq-sub-label">Lost</span>
<span class="sq-sub-desc">Send to Pending Lost</span>
</button>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "revision"}
on:click={() => selectSub("revision")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/><path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/></svg
>
<span class="sq-sub-label">Needs Revision</span>
<span class="sq-sub-desc"
>Requires changes before finalizing</span
>
</button>
</div>
{#if (selectedSubOption === "won" || selectedSubOption === "lost") && canFinalize}
<div class="sq-finalize-row" transition:slide={{ duration: 150 }}>
<label class="sq-finalize-check">
<input type="checkbox" bind:checked={finalizeImmediately} />
<span>Finalize immediately</span>
<span class="sq-finalize-hint"
>{finalizeImmediately
? "Will finalize directly"
: "Will go to pending review"}</span
>
</label>
</div>
{/if}
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="sq-time-start">Start</label>
<input
id="sq-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="sq-time-end">End</label>
<input
id="sq-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
</div>
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSubmit}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,304 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowAction,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let title = "Workflow Action";
export let actionName: WorkflowAction;
export let requiresNote = false;
export let needsConfirmation = false;
export let confirmationMessage = "";
export let isDestructive = false;
export let error: string | null = null;
export let isSubmitting = false;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let showConfirmation = false;
let noteError = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
function validate(): boolean {
noteError = "";
if (requiresNote && !note.trim()) {
noteError = "A note is required for this action.";
return false;
}
return true;
}
function handleSubmit() {
if (!validate()) return;
if (needsConfirmation && !showConfirmation) {
showConfirmation = true;
return;
}
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleConfirm() {
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
showConfirmation = false;
noteError = "";
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal" on:click|stopPropagation>
<div class="wf-modal-header" class:wf-destructive={isDestructive}>
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
{#if showConfirmation}
<div class="wf-confirmation-box">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" /><line
x1="12"
y1="17"
x2="12.01"
y2="17"
/>
</svg>
<p>{confirmationMessage}</p>
<div class="wf-confirmation-actions">
<button
class="wf-btn wf-btn-secondary"
on:click={() => (showConfirmation = false)}
disabled={isSubmitting}
>
Go Back
</button>
<button
class="wf-btn {isDestructive
? 'wf-btn-destructive'
: 'wf-btn-primary'}"
on:click={handleConfirm}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Confirm
{/if}
</button>
</div>
</div>
{:else}
<!-- Note field -->
<div class="wf-field-group">
<label class="wf-field-label" for="wf-note">
Note {#if requiresNote}<span class="wf-required">*</span>{/if}
</label>
<textarea
id="wf-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder={requiresNote
? "Required — describe this action"
: "Optional note"}
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="wf-time-start">Start</label
>
<input
id="wf-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="wf-time-end">End</label>
<input
id="wf-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if !showConfirmation}
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn {isDestructive
? 'wf-btn-destructive'
: 'wf-btn-primary'}"
on:click={handleSubmit}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,558 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { clientFetch } from "$lib/client-fetch";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type {
WorkflowStatusResponse,
WorkflowAvailableAction,
WorkflowAction,
WorkflowActionPayload,
WorkflowResult,
} from "$lib/optima-api/modules/sales";
import {
WORKFLOW_STATUS_LABELS,
STATUS_ID_TO_KEY,
TERMINAL_STATUSES,
REOPENABLE_STATUSES,
QUOTE_CONFIRMED_STATUSES,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import type {
CommittedQuote,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import WorkflowActionModal from "./WorkflowActionModal.svelte";
import SendQuoteModal from "./SendQuoteModal.svelte";
import ReviewDecisionModal from "./ReviewDecisionModal.svelte";
import FinalizeModal from "./FinalizeModal.svelte";
const dispatch = createEventDispatcher();
export let opportunity: SalesOpportunity | null;
export let workflowStatus: WorkflowStatusResponse | null;
export let permissions: PermissionMap;
export let opportunityId: string;
export let inline: boolean = false;
export let quotes: CommittedQuote[] = [];
export let activities: OpportunityActivity[] = [];
// Workflow state
let activeModal: WorkflowAction | null = null;
let workflowError: string | null = null;
let isSubmitting = false;
let preselectedOutcome: "won" | "lost" | null = null;
// Derived state
$: statusKey = workflowStatus
? (STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null)
: null;
$: statusLabel = statusKey
? WORKFLOW_STATUS_LABELS[statusKey]
: (workflowStatus?.currentStatus ?? opportunity?.status?.name ?? "Unknown");
$: isTerminal = statusKey ? TERMINAL_STATUSES.has(statusKey) : false;
$: isReopenable = statusKey ? REOPENABLE_STATUSES.has(statusKey) : false;
$: quoteConfirmed = statusKey
? QUOTE_CONFIRMED_STATUSES.has(statusKey)
: false;
$: isOptimaStage = workflowStatus?.isOptimaStage ?? false;
$: isCold = workflowStatus?.coldCheck?.isCold ?? false;
$: isPending =
statusKey === "PendingWon" ||
statusKey === "PendingLost" ||
statusKey === "PendingSent" ||
statusKey === "PendingRevision" ||
statusKey === "ReadyToSend" ||
statusKey === "PendingNew";
$: isPendingOutcome =
statusKey === "PendingWon" || statusKey === "PendingLost";
$: canFinalize = permissions["sales.opportunity.finalize"] !== false;
$: canWin = permissions["sales.opportunity.win"] === true;
$: canLose = permissions["sales.opportunity.lose"] === true;
$: canCancel = permissions["sales.opportunity.cancel"] !== false;
$: canReview = permissions["sales.opportunity.review"] !== false;
$: canSend = permissions["sales.opportunity.send"] !== false;
$: canReopen = permissions["sales.opportunity.reopen"] !== false;
$: hasQuotes = quotes.length > 0;
/** For resendQuote: check that a quote was generated after the last revision opening */
$: hasQuoteSinceRevision = (() => {
if (!hasQuotes) return false;
// Find the most recent "Revision" or "beginRevision" activity by looking at
// customFields Optima_Type === "Revision" on activities
const revisionActivities = activities.filter((a) => {
const optimaType = a.customFields?.find(
(f) => f.caption === "Optima_Type",
)?.value;
return optimaType === "Revision";
});
if (revisionActivities.length === 0) return true; // no revisions, any quote is fine
// Get the most recent revision date
const latestRevisionDate = revisionActivities.reduce((latest, a) => {
const d = a.cwDateEntered ?? a.dateStart ?? "";
return d > latest ? d : latest;
}, "");
if (!latestRevisionDate) return true;
// Check if any quote was created after the latest revision
return quotes.some((q) => q.createdAt > latestRevisionDate);
})();
/** Reason text for disabled send/resend buttons */
$: sendQuoteDisabledReason = !hasQuotes
? "Generate a quote before sending"
: null;
$: resendQuoteDisabledReason = !hasQuotes
? "Generate a quote before sending"
: !hasQuoteSinceRevision
? "Generate a new quote before resending"
: null;
$: availableActions = (
workflowStatus?.availableActions?.filter((a) => a.permitted) ?? []
).filter((a) => actionPermitted(a.action));
/** Synthesize a resurrect action for PendingWon/PendingLost when user has finalize perm */
$: resurrectFromPending =
isPendingOutcome &&
canFinalize &&
!availableActions.some((a) => a.action === "resurrect")
? ({
action: "resurrect" as WorkflowAction,
label: "Open for Revision",
targetStatuses: [{ key: "Active", id: 58 }],
requiresNote: true,
requiresPermission: "sales.opportunity.finalize",
permitted: true,
payloadHints: {} as Record<string, string>,
} satisfies WorkflowAvailableAction)
: null;
/** All actions including the synthesized resurrect */
$: allActions = resurrectFromPending
? [...availableActions, resurrectFromPending]
: availableActions;
/** Map workflow action names to their required permission */
function actionPermitted(action: WorkflowAction): boolean {
switch (action) {
case "requestReview":
case "reviewDecision":
return canReview;
case "sendQuote":
case "ReadyToSend":
case "resendQuote":
return canSend;
case "finalize":
return canFinalize || canWin || canLose;
case "cancel":
return canCancel;
case "reopen":
return canReopen;
case "resurrect":
// From PendingWon/PendingLost requires finalize perm; from terminal requires reopen
return (isPendingOutcome && canFinalize) || canReopen;
default:
return true; // confirmQuote, beginRevision, acceptNew — base workflow perm
}
}
/** CSS class for the workflow status badge */
function workflowStatusClass(key: string | null): string {
if (!key) return "wf-status-default";
const map: Record<string, string> = {
PendingNew: "wf-status-pending-new",
New: "wf-status-new",
InternalReview: "wf-status-review",
QuoteSent: "wf-status-quote-sent",
ConfirmedQuote: "wf-status-quote-confirmed",
Active: "wf-status-active",
ReadyToSend: "wf-status-ready-to-send",
PendingSent: "wf-status-pending-sent",
PendingRevision: "wf-status-pending-revision",
PendingWon: "wf-status-pending-won",
Won: "wf-status-won",
PendingLost: "wf-status-pending-lost",
Lost: "wf-status-lost",
Canceled: "wf-status-canceled",
};
return map[key] ?? "wf-status-default";
}
/** Forward-progression actions in priority order */
const FORWARD_ACTIONS: WorkflowAction[] = [
"acceptNew",
"requestReview",
"reviewDecision",
"sendQuote",
"ReadyToSend",
"resendQuote",
"confirmQuote",
"beginRevision",
];
/** Get display label for an action */
function actionLabel(action: WorkflowAvailableAction): string {
// Context-dependent label for resurrect
if (action.action === "resurrect" && isPendingOutcome) {
return "Open for Revision";
}
const overrides: Partial<Record<WorkflowAction, string>> = {
requestReview: "Request Review",
reviewDecision: "Complete Review",
sendQuote: "Quote Sent",
ReadyToSend: "Ready to Send",
resendQuote: "Quote Resent",
confirmQuote: "Quote Confirmed",
beginRevision: "Begin Revising",
resurrect: "Resurrect",
cancel: "Cancel",
reopen: "Reopen",
acceptNew: "Accept",
};
return overrides[action.action] ?? action.label;
}
/** Open the appropriate modal for an action */
function openAction(action: WorkflowAvailableAction) {
workflowError = null;
activeModal = action.action;
}
function closeModal() {
activeModal = null;
workflowError = null;
preselectedOutcome = null;
}
/** Submit a workflow action */
async function submitAction(
action: WorkflowAction,
payload: WorkflowActionPayload,
) {
isSubmitting = true;
workflowError = null;
try {
const json = await clientFetch<{
successful: boolean;
message?: string;
data?: { error?: string };
}>(`/sales/opportunity/${opportunityId}/workflow`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, payload }),
});
if (json.successful === false) {
workflowError =
json.message ?? json.data?.error ?? "Workflow action failed";
return null;
}
// Success — close modal, merge new activities for immediate gating update, and refresh
closeModal();
const result = json.data as WorkflowResult;
if (result.activitiesCreated?.length) {
activities = [...result.activitiesCreated, ...activities];
}
dispatch("workflowChanged", result);
return result;
} catch (err) {
workflowError =
err instanceof Error ? err.message : "Workflow action failed";
return null;
} finally {
isSubmitting = false;
}
}
/** Handle SendQuote modal wanting to chain into review */
function handleSendQuoteChain(e: CustomEvent<{ action: WorkflowAction }>) {
activeModal = e.detail.action;
}
/** Determine if action needs a special modal */
function isSpecialModal(action: WorkflowAction): boolean {
return ["sendQuote", "resendQuote", "reviewDecision", "finalize"].includes(
action,
);
}
/** Actions that need a confirmation dialog before submission */
function needsConfirmation(action: WorkflowAction): boolean {
return action === "cancel" || action === "reopen" || action === "resurrect";
}
/** Confirmation messages */
function confirmationMessage(action: WorkflowAction): string {
if (action === "cancel") {
return "This cannot be undone unless the opportunity is re-opened. Are you sure?";
}
if (action === "reopen") {
return "Re-opening will set status to Active and log a full audit trail entry. Continue?";
}
if (action === "resurrect") {
return "This will move the opportunity back to Active for further revision. Continue?";
}
return "";
}
/** Get the finalize actions split by outcome (only after quote confirmed, gated by win/lose perms) */
$: finalizeWonAction =
quoteConfirmed && canWin
? allActions.find(
(a) =>
a.action === "finalize" &&
(a.payloadHints as Record<string, string>)?.["outcome"] === '"won"',
)
: undefined;
$: finalizeLostAction =
quoteConfirmed && canLose
? allActions.find(
(a) =>
a.action === "finalize" &&
(a.payloadHints as Record<string, string>)?.["outcome"] ===
'"lost"',
)
: undefined;
$: nonFinalizeActions = allActions
.filter((a) => a.action !== "finalize")
.filter((a, i, arr) => arr.findIndex((b) => b.action === a.action) === i);
/** The main forward-progression action (shown as filled primary button) */
$: primaryAction =
nonFinalizeActions.find((a) => FORWARD_ACTIONS.includes(a.action)) ?? null;
/** Everything else (shown as ghost buttons) */
$: secondaryActions = nonFinalizeActions.filter((a) => a !== primaryAction);
</script>
{#if workflowStatus && isOptimaStage}
<div class="wf-panel" class:wf-panel-inline={inline}>
<!-- Error Banner -->
{#if workflowError}
<div class="wf-error-banner">
<span class="wf-error-text">{workflowError}</span>
<button
class="wf-error-dismiss"
on:click={() => (workflowError = null)}
aria-label="Dismiss error"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
{/if}
<!-- Action Buttons -->
{#if !isTerminal && allActions.length > 0}
<div class="wf-actions">
<!-- Primary forward action (filled button) -->
{#if primaryAction}
{@const isSendGated =
primaryAction.action === "sendQuote" && !!sendQuoteDisabledReason}
{@const isResendGated =
primaryAction.action === "resendQuote" &&
!!resendQuoteDisabledReason}
{@const gatedReason = isSendGated
? sendQuoteDisabledReason
: isResendGated
? resendQuoteDisabledReason
: null}
{#if gatedReason}
<span class="wf-tooltip-wrap" data-tooltip={gatedReason}>
<button class="wf-action-btn wf-btn-primary" disabled
>{actionLabel(primaryAction)}</button
>
</span>
{:else}
<button
class="wf-action-btn wf-btn-primary"
on:click={() => openAction(primaryAction)}
disabled={isSubmitting}>{actionLabel(primaryAction)}</button
>
{/if}
{/if}
<!-- Secondary actions + finalize (ghost buttons) -->
{#if secondaryActions.length > 0 || finalizeWonAction || finalizeLostAction}
{#if primaryAction}
<span class="wf-action-sep" aria-hidden="true"></span>
{/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}
<span class="wf-tooltip-wrap" data-tooltip={secGatedReason}>
<button class="wf-action-btn wf-btn-ghost" disabled
>{actionLabel(action)}</button
>
</span>
{:else}
<button
class="wf-action-btn wf-btn-ghost"
class:wf-ghost-danger={action.action === "cancel"}
on:click={() => openAction(action)}
disabled={isSubmitting}>{actionLabel(action)}</button
>
{/if}
{/each}
{#if finalizeWonAction}
<button
class="wf-action-btn wf-btn-ghost wf-ghost-success"
on:click={() => {
preselectedOutcome = "won";
openAction(finalizeWonAction);
}}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"><polyline points="20 6 9 17 4 12" /></svg
>
Won
</button>
{/if}
{#if finalizeLostAction}
<button
class="wf-action-btn wf-btn-ghost wf-ghost-danger"
on:click={() => {
preselectedOutcome = "lost";
openAction(finalizeLostAction);
}}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
><line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/></svg
>
Lost
</button>
{/if}
{/if}
</div>
{/if}
<!-- Terminal / Locked indicator -->
{#if isTerminal}
<div class="wf-terminal-notice">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
d="M7 11V7a5 5 0 0110 0v4"
/>
</svg>
This opportunity is finalized and locked.
</div>
{/if}
</div>
{/if}
<!-- ── Modals ── -->
<!-- Generic action modal (requestReview, confirmQuote, beginRevision, resurrect, cancel, reopen, acceptNew) -->
{#if activeModal && !isSpecialModal(activeModal)}
{@const action = allActions.find((a) => a.action === activeModal)}
{#if action}
<WorkflowActionModal
isOpen={true}
title={actionLabel(action)}
actionName={action.action}
requiresNote={action.requiresNote}
needsConfirmation={needsConfirmation(action.action)}
confirmationMessage={confirmationMessage(action.action)}
isDestructive={action.action === "cancel"}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction(action.action, e.detail)}
on:close={closeModal}
/>
{/if}
{/if}
<!-- SendQuote modal -->
{#if activeModal === "sendQuote" || activeModal === "resendQuote"}
<SendQuoteModal
isOpen={true}
{canFinalize}
error={workflowError}
{isSubmitting}
actionName={activeModal}
on:submit={(e) => submitAction(activeModal ?? "sendQuote", e.detail)}
on:close={closeModal}
/>
{/if}
<!-- ReviewDecision modal -->
{#if activeModal === "reviewDecision"}
<ReviewDecisionModal
isOpen={true}
{canCancel}
{canSend}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction("reviewDecision", e.detail)}
on:sendQuote={() => {
activeModal = "sendQuote";
}}
on:close={closeModal}
/>
{/if}
<!-- Finalize modal -->
{#if activeModal === "finalize"}
{@const wonAction = finalizeWonAction}
{@const lostAction = finalizeLostAction}
<FinalizeModal
isOpen={true}
{statusKey}
hasWon={!!wonAction}
hasLost={!!lostAction}
{canFinalize}
initialOutcome={preselectedOutcome}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction("finalize", e.detail)}
on:close={closeModal}
/>
{/if}
@@ -0,0 +1,27 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** POST /sales/opportunity/[id]/notes — create a note */
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.text?.trim()) throw error(400, "Note text is required");
try {
const result = await optima.sales.createNote(accessToken, params.id, {
text: body.text.trim(),
flagged: body.flagged ?? false,
});
return json(result, { status: 201 });
} catch (err: unknown) {
console.error("Failed to create note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to create note");
}
};
@@ -0,0 +1,63 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** PATCH /sales/opportunity/[id]/notes/[noteId] — update a note */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const noteId = Number(params.noteId);
if (isNaN(noteId)) throw error(400, "Invalid note ID");
const body = await request.json();
if (!body.text?.trim() && body.flagged === undefined) {
throw error(400, "At least text or flagged must be provided");
}
const payload: { text?: string; flagged?: boolean } = {};
if (body.text?.trim()) payload.text = body.text.trim();
if (body.flagged !== undefined) payload.flagged = body.flagged;
try {
const result = await optima.sales.updateNote(
accessToken,
params.id,
noteId,
payload,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to update note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to update note");
}
};
/** DELETE /sales/opportunity/[id]/notes/[noteId] — delete a note */
export const DELETE: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const noteId = Number(params.noteId);
if (isNaN(noteId)) throw error(400, "Invalid note ID");
try {
const result = await optima.sales.deleteNote(
accessToken,
params.id,
noteId,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to delete note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to delete note");
}
};
@@ -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 });
});
});
});
@@ -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,
});
});
});
@@ -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,
});
});
});
@@ -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();
});
});
@@ -0,0 +1,298 @@
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;
forecastMonth?: string;
revenue?: number;
cost?: number;
forecastPercentage?: number;
status?: { id: number; name: string };
includedFlag?: boolean;
linkedFlag?: boolean;
recurringFlag?: boolean;
[key: string]: unknown;
}
export interface NoteAuthor {
id: string;
identifier: string;
name: string;
cwMemberId?: number;
}
export interface OpportunityNote {
id: number;
text?: string;
type?: { id: number; name: string };
flagged?: boolean;
enteredBy?: NoteAuthor | null;
dateEntered?: string;
_info?: { lastUpdated?: string; updatedBy?: string };
[key: string]: unknown;
}
export function noteAuthorInitials(name?: string): string {
if (!name) return "?";
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
}
export function formatDateTime(dateStr?: string | null): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return "";
}
}
export interface OpportunityProduct {
id: number;
forecastDescription?: string;
productDescription?: string;
productClass?: string;
forecastType?: string;
quantity?: number;
revenue?: number;
cost?: number;
margin?: number;
profit?: number;
percentage?: number;
status?: { id: number; name: string };
catalogItem?: { id: number; identifier: string };
opportunity?: { id: number; name: string };
includeFlag?: boolean;
linkFlag?: boolean;
recurringFlag?: boolean;
taxableFlag?: boolean;
cancelled?: boolean;
cancellationType?: "full" | "partial" | null;
quantityCancelled?: number;
cancelledReason?: string | null;
cancelledDate?: string | null;
recurringRevenue?: number;
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
subNumber?: number;
cwLastUpdated?: string;
cwUpdatedBy?: string;
onHand?: number | null;
inStock?: boolean | null;
productNarrative?: string | null;
customerDescription?: string | null;
procurementNotes?: string | null;
[key: string]: unknown;
}
export interface OpportunityContact {
id: number;
contact?: { id: number; name: string };
company?: { id: number; identifier?: string; name: string };
role?: { id: number; name: string };
notes?: string;
referralFlag?: boolean;
[key: string]: unknown;
}
export interface PageData {
opportunity: SalesOpportunity | null;
opportunityId: string;
notes: OpportunityNote[];
contacts: OpportunityContact[];
products: OpportunityProduct[];
quotes: CommittedQuote[];
accessToken: string | null;
permissions: PermissionMap;
workflowStatus: WorkflowStatusResponse | null;
}
export function opportunityInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
// Append T00:00:00 to date-only strings so they parse as local midnight
const normalized = dateStr.includes("T") ? dateStr : dateStr + "T00:00:00";
return new Date(normalized).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
export function formatCurrency(amount?: number | null): string {
if (amount == null) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(amount);
}
/**
* Canonical status IDs pipeline tier.
* Equivalency IDs (legacy/pre-2024) are mapped to the same tier as their canonical parent.
*/
const STATUS_TIER: Record<number, string> = (() => {
const map: Record<number, string> = {};
// FutureLead (id 51) + equivalencies
for (const id of [51, 35, 36]) map[id] = "status-future";
// 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";
// QuoteSent (id 43)
map[43] = "status-quote-sent";
// ConfirmedQuote (id 57)
map[57] = "status-quote-confirmed";
// ReadyToSend (id 63)
map[63] = "status-ready-to-send";
// 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, 38, 39, 40, 42, 44, 45, 46, 47, 48, 52,
55,
])
map[id] = "status-active";
// 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<number, string> = {
51: "FutureLead",
37: "Pending New",
24: "New",
56: "Internal Review",
43: "Quote Sent",
57: "Confirmed Quote",
63: "Ready to Send",
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, 37, 24, 56, 43, 57, 63, 60, 61, 58, 49, 29, 50, 53, 59,
]);
export function statusColorClass(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) {
const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-closed";
}
const sid = opportunity.status?.id;
if (sid != null && STATUS_TIER[sid]) return STATUS_TIER[sid];
return "status-open";
}
/** Get the canonical display label for the status (resolves equivalencies). */
export function statusLabel(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) {
const sid = opportunity.status?.id;
if (sid != null) {
for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
const tier = STATUS_TIER[sid];
const canonTier = STATUS_TIER[Number(canonId)];
if (tier && tier === canonTier) return name;
}
}
return "Closed";
}
const sid = opportunity.status?.id;
if (sid != null) {
// If it IS the canonical ID, just use its name
if (CANONICAL_IDS.has(sid))
return CANONICAL_NAMES[sid] ?? opportunity.status?.name ?? "Open";
// Otherwise it's an equivalency — return the canonical name
const tier = STATUS_TIER[sid];
if (tier) {
for (const [canonId, name] of Object.entries(CANONICAL_NAMES)) {
if (STATUS_TIER[Number(canonId)] === tier) return name;
}
}
}
return opportunity.status?.name ?? "Open";
}
/** Whether this status is an equivalency-mapped status (not canonical). */
export function isEquivalencyStatus(opportunity: SalesOpportunity): boolean {
const sid = opportunity.status?.id;
if (sid == null) return false;
return STATUS_TIER[sid] != null && !CANONICAL_IDS.has(sid);
}
/** The original CW status name (for tooltip on equivalency statuses). */
export function originalStatusName(opportunity: SalesOpportunity): string {
return opportunity.status?.name ?? "Unknown";
}
@@ -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<string, unknown> | undefined;
if (err && typeof err === "object" && "response" in err) {
const axiosErr = err as {
response?: { data?: Record<string, unknown>; 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");
}
};
@@ -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);
}
};

Some files were not shown because too many files have changed in this diff Show More