fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>© {new Date().getFullYear()} Total Tech Solutions, LLC</small>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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()} (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 “{searchQuery}”. 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
(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 “{searchQuery}”. 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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} by
|
||||
<strong>{selectedConfig.info.enteredBy}</strong
|
||||
>{/if}{#if selectedConfig.info.dateEntered} 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} by
|
||||
<strong>{selectedConfig.info.updatedBy}</strong
|
||||
>{/if}{#if selectedConfig.info.lastUpdated} 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user