feat: add procurement and sales sections

This commit is contained in:
2026-02-27 14:42:19 -06:00
parent 7486bcf939
commit 5a6970a4c5
24 changed files with 4739 additions and 134 deletions
+125 -3
View File
@@ -1,8 +1,118 @@
// src/hooks.server.ts
import { optima } from "$lib";
import { isInvalidSignatureError } from "$lib/optima-api/errorHandler";
import { redirect, type Handle } from "@sveltejs/kit";
import api from "$lib/optima-api/axios";
function apiUnreachablePage(): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Service Unavailable — Project Optima</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1117;
color: #e4e4e7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.container {
text-align: center;
max-width: 480px;
padding: 2rem;
}
.icon {
margin-bottom: 1.5rem;
}
.icon svg {
width: 96px;
height: 96px;
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.75rem;
color: #f87171;
}
p {
font-size: 1rem;
line-height: 1.6;
color: #a1a1aa;
margin-bottom: 1.5rem;
}
.retry-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
background: #dc2626;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
text-decoration: none;
}
.retry-btn:hover { background: #b91c1c; }
.status {
margin-top: 2rem;
font-size: 0.8rem;
color: #52525b;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<svg viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="48" cy="48" r="44" stroke="#dc2626" stroke-width="3" opacity="0.2"/>
<circle cx="48" cy="48" r="32" fill="#1c1c22"/>
<path d="M36 36L60 60M60 36L36 60" stroke="#dc2626" stroke-width="4" stroke-linecap="round"/>
</svg>
</div>
<h1>Unable to Reach API</h1>
<p>
Project Optima cannot connect to the API server. This may be due to a
network issue or the API server being temporarily unavailable.
</p>
<a class="retry-btn" onclick="window.location.reload()" role="button" tabindex="0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Retry
</a>
<p class="status">If this persists, contact your system administrator.</p>
</div>
</body>
</html>`;
return new Response(html, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
export const handle: Handle = async ({ event, resolve }) => {
// Health-check the API before doing anything else.
// /v1/teapot returns 418 when the API is alive.
try {
const health = await api.get("/v1/teapot", {
timeout: 5000,
validateStatus: () => true,
});
if (health.status !== 418) throw new Error("Unexpected status");
} catch {
return apiUnreachablePage();
}
const accessToken = event.cookies.get("accessToken") || null;
const refreshToken = event.cookies.get("refreshToken") || null;
@@ -52,7 +162,13 @@ export const handle: Handle = async ({ event, resolve }) => {
return redirect(303, "/login");
}
}
} catch {
} catch (err) {
// Invalid signature means tokens are fundamentally bad — don't attempt refresh
if (isInvalidSignatureError(err)) {
console.warn("Invalid token signature detected — forcing logout.");
optima.user.logout(event);
return redirect(303, "/login");
}
// Token is malformed or refresh failed — try refresh as fallback
if (currentRefreshToken) {
try {
@@ -60,7 +176,10 @@ export const handle: Handle = async ({ event, resolve }) => {
await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
} catch {
} catch (refreshErr) {
if (isInvalidSignatureError(refreshErr)) {
console.warn("Invalid refresh token signature — forcing logout.");
}
// Refresh also failed, force re-login
optima.user.logout(event);
return redirect(303, "/login");
@@ -76,7 +195,10 @@ export const handle: Handle = async ({ event, resolve }) => {
const refreshed = await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
} catch {
} catch (err) {
if (isInvalidSignatureError(err)) {
console.warn("Invalid refresh token signature — forcing logout.");
}
optima.user.logout(event);
return redirect(303, "/login");
}
+4
View File
@@ -9,6 +9,8 @@ import { permission } from "./optima-api/modules/permissions";
import { user } from "./optima-api/modules/user";
import { users } from "./optima-api/modules/users";
import { unifi } from "./optima-api/modules/unifi";
import { procurement } from "./optima-api/modules/procurement";
import { sales } from "./optima-api/modules/sales";
export const optima = {
auth,
@@ -20,6 +22,8 @@ export const optima = {
user,
users,
unifi,
procurement,
sales,
};
/**
* @TODO
+35 -1
View File
@@ -1,4 +1,4 @@
import { error } from "@sveltejs/kit";
import { error, redirect } from "@sveltejs/kit";
export class ApiError extends Error {
constructor(
@@ -11,9 +11,43 @@ export class ApiError extends Error {
}
}
/**
* Detects "invalid signature" or malformed-token errors from the API,
* which indicate the access or refresh token has been tampered with or
* the server signing key has changed.
*/
export function isInvalidSignatureError(err: unknown): boolean {
if (err && typeof err === "object") {
const axiosErr = err as Record<string, unknown>;
const responseData = (axiosErr?.response as Record<string, unknown>)
?.data as Record<string, unknown> | undefined;
const candidates = [
responseData?.message,
responseData?.error,
(err as Error)?.message,
];
return candidates.some((val) => {
if (typeof val !== "string") return false;
const lower = val.toLowerCase();
return (
lower.includes("invalid signature") ||
lower.includes("jwt malformed") ||
lower.includes("invalid token")
);
});
}
return false;
}
export function handleApiError(err: unknown): never {
console.error("API Error:", err);
// Treat invalid-signature errors as a forced logout
if (isInvalidSignatureError(err)) {
console.warn("Invalid token signature detected — forcing logout.");
throw redirect(303, "/logout");
}
if (err instanceof ApiError) {
throw error(err.statusCode, {
message: err.message,
+104
View File
@@ -0,0 +1,104 @@
import api from "../axios";
export const procurement = {
async fetchMany(
accessToken: string,
page: number = 1,
search?: string,
rpp: number = 30,
includeInactive: boolean = false,
) {
const params: Record<string, unknown> = { page, rpp };
if (search && search.length > 0) params.search = search;
if (includeInactive) params.includeInactive = true;
const response = await api.get("/v1/procurement/items", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetch(
accessToken: string,
identifier: string,
options?: { includeLinkedItems?: boolean },
) {
const params: Record<string, string> = {};
if (options?.includeLinkedItems) params.includeLinkedItems = "true";
const response = await api.get(`/v1/procurement/items/${identifier}`, {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async count(accessToken: string, activeOnly: boolean = false) {
const params: Record<string, string> = {};
if (activeOnly) params.activeOnly = "true";
const response = await api.get("/v1/procurement/count", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data.count;
},
async refreshInventory(accessToken: string, identifier: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/refresh-inventory`,
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async fetchLinkedItems(accessToken: string, identifier: string) {
const response = await api.get(
`/v1/procurement/items/${identifier}/linked`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async linkItem(accessToken: string, identifier: string, targetId: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/link`,
{ targetId },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async unlinkItem(accessToken: string, identifier: string, targetId: string) {
const response = await api.post(
`/v1/procurement/items/${identifier}/unlink`,
{ targetId },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
};
+62
View File
@@ -0,0 +1,62 @@
import api from "../axios";
export interface SalesOpportunity {
id: string;
cwOpportunityId?: number;
name: string;
notes?: string | null;
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;
source?: string | null;
campaign?: 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;
contact?: { id?: number | string; name?: string } | null;
site?: { id?: number | string; name?: string } | null;
customerPO?: string | null;
totalSalesTax?: number | null;
expectedCloseDate?: string | null;
pipelineChangeDate?: string | null;
dateBecameLead?: string | null;
closedDate?: string | null;
closedFlag?: boolean;
closedBy?: string | null;
companyId?: string;
cwLastUpdated?: string | null;
createdAt?: string;
updatedAt?: string;
}
export const sales = {
async fetchMany(
accessToken: string,
page: number = 1,
search: string = "",
rpp: number = 30,
includeClosed: boolean = true,
) {
const params: Record<string, unknown> = { page, rpp };
if (search) params.search = search;
if (includeClosed) params.includeClosed = true;
const response = await api.get("/v1/sales/opportunities", {
params,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
+3
View File
@@ -11,6 +11,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
let canViewAdmin = false;
try {
const userInfo = await optima.user.fetchInfo(accessToken);
console.log("@me response:", JSON.stringify(userInfo, null, 2));
const permResult = await optima.user.checkPermissions(accessToken, [
"ui.navigation.admin.view",
]);
+13
View File
@@ -1,7 +1,9 @@
<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";
const navItems = [
{
@@ -15,6 +17,16 @@
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 = {
@@ -33,6 +45,7 @@
{#if $page.route.id?.startsWith("/(auth)")}
<slot />
{:else}
<LoadingSpinner loading={!!$navigating} />
<div class="layout-container">
<header class="header">
<div class="header-content">
+18 -12
View File
@@ -4,31 +4,37 @@ import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, parent }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Check the top-level admin gate + all per-tab permissions in one call
const permissions = await checkPermissions(accessToken, [
"ui.navigation.admin.view",
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
]);
// Grab the root layout data to reuse the admin-view permission it already checked
const parentData = await parent();
if (!permissions["ui.navigation.admin.view"]) {
// If the root layout already determined we can't view admin, redirect immediately
if (parentData?.canViewAdmin === false) {
throw redirect(303, "/");
}
// Fetch current user info for the dashboard greeting
const userInfo = await optima.user.fetchInfo(accessToken);
// 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",
]),
optima.user.fetchInfo(accessToken),
]);
return {
user: userInfo?.data ?? null,
permissions,
permissions: {
"ui.navigation.admin.view": true, // Already verified via parent
...permissions,
},
};
} catch (err) {
// Re-throw redirects so SvelteKit handles them
+19 -6
View File
@@ -30,6 +30,7 @@
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
@@ -38,6 +39,7 @@
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
isUserTyping = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
@@ -53,30 +55,41 @@
const params = new URLSearchParams();
params.set("page", String(p));
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
goto(`/companies?${params.toString()}`, { replaceState: true });
}
function handleSearch() {
isSearching = true;
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()}`);
}, 300);
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()}`);
goto(`/companies?${params.toString()}`, {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}
}
@@ -208,7 +221,7 @@
<!-- Pane body -->
<div class="pane-body">
{#if isSearching}
{#if isSearching && !isUserTyping}
<div class="search-loading-overlay">
<div class="search-spinner"></div>
</div>
+21 -20
View File
@@ -1,6 +1,6 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { resolvePermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
@@ -18,40 +18,41 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}
try {
// Run permission checks in parallel with other data fetches.
// Add any new permissions the company page needs to this array.
// Permissions are resolved locally from the Set populated in hooks — no API call
const permissions = resolvePermissions(locals.userPermissions, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]);
// All data fetches can now run in parallel — no permissions waterfall
const [
permissions,
companyResult,
configsResult,
credentialsResult,
credentialTypesResult,
unifiSitesResult,
] = await Promise.all([
checkPermissions(accessToken, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]),
optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
}),
optima.company.fetchConfigurations(accessToken, params.id),
optima.credential
.fetchByCompany(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
optima.credentialType
.fetchMany(accessToken)
.catch(() => ({ data: [] })),
optima.unifi
.fetchCompanySites(accessToken, params.id)
.catch(() => ({ data: [] })),
]);
// Fetch company with or without address based on permission
const companyResult = await optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
});
return {
company: companyResult?.data ?? null,
configurations: configsResult?.data ?? [],
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/procurement.css";
const tabs = [
{ label: "Product Catalog", href: "/procurement/catalog", exact: false },
] as const;
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Procurement — Project Optima</title>
</svelte:head>
<div class="procurement-page">
<div class="procurement-pane">
<!-- Pane header + tabs in one row -->
<div class="procurement-header">
<div class="procurement-header-left">
<svg
class="procurement-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<h2 class="procurement-title">Procurement</h2>
</div>
<nav class="tab-bar" role="tablist">
{#each tabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</nav>
</div>
<!-- Tab content -->
<div class="procurement-body">
<slot />
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
onMount(() => {
goto("/procurement/catalog", { replaceState: true });
});
</script>
@@ -0,0 +1,60 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
items: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
permissions: {},
};
}
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
const includeInactive = url.searchParams.get("includeInactive") === "true";
try {
const [result, permissions] = await Promise.all([
optima.procurement
.fetchMany(accessToken, page, search, 30, includeInactive)
.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,30 @@
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,
query,
20,
true,
);
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");
}
};
+79
View File
@@ -0,0 +1,79 @@
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: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
includeClosed: true,
permissions: {},
};
}
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const search = url.searchParams.get("search") || "";
const includeClosed = url.searchParams.get("includeClosed") !== "false";
try {
const [result, permissions] = await Promise.all([
optima.sales
.fetchMany(accessToken, page, search, 30, 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 },
},
};
}),
checkPermissions(accessToken, ["sales.opportunity.fetch.many"]),
]);
console.log("Sales opportunities raw result:", {
page,
search,
includeClosed,
resultSummary: {
hasData: Boolean(result?.data),
keys: result?.data ? Object.keys(result.data) : [],
meta: result?.meta ?? result?.data?.meta ?? null,
},
});
const opportunities =
result?.data?.data ??
result?.data?.opportunities ??
result?.data ??
[];
const pagination =
result?.meta?.pagination ?? result?.data?.meta?.pagination ?? null;
console.log("Sales opportunities normalized:", {
count: opportunities?.length ?? 0,
pagination,
});
return {
opportunities,
totalPages: pagination?.totalPages ?? 1,
currentPage: pagination?.currentPage ?? page,
totalRecords: pagination?.totalRecords ?? opportunities.length ?? 0,
search,
includeClosed,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
+457
View File
@@ -0,0 +1,457 @@
<script lang="ts">
import { goto, afterNavigate } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../components/NoResultsMonkey.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[];
totalPages: number;
currentPage: number;
totalRecords: number;
search: string;
includeClosed: boolean;
};
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
let isUserTyping = false;
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);
});
$: 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?${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);
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);
navigateWithFilters({ page: 1, keepFocus: true });
}
}
function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
function statusLabel(op: SalesOpportunity): string {
if (op.closedFlag) return "Closed";
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 priorityLabel(op: SalesOpportunity): string {
return op.priority?.name || "—";
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script>
<svelte:window on:click={handleFilterClickOutside} />
<svelte:head>
<title>Sales — Project Optima</title>
</svelte:head>
{#if !hasAccess}
<div class="sales-access-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view Sales opportunities. Contact your
administrator to request access.
</p>
</div>
{: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>
<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 isSearching && !isUserTyping}
<div class="sales-loading-overlay">
<div class="sales-spinner"></div>
</div>
{/if}
{#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-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-priority">Priority</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}>
<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-stage">{opp.stage?.name || "—"}</td>
<td class="col-status">
<span
class="sales-status-badge"
class:status-closed={opp.closedFlag}
class:status-open={!opp.closedFlag}
>
{statusLabel(opp)}
</span>
</td>
<td class="col-priority">
<span class="sales-priority">
{priorityLabel(opp)}
</span>
</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>
<nav class="sales-pagination" aria-label="Sales pagination">
<button
class="sales-page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="sales-page-ellipsis"></span>
{:else}
<button
class="sales-page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="sales-page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div>
{/if}
</div>
</div>
{/if}
<style>
.sales-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.sales-access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.sales-access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sales-access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style>
+1 -5
View File
@@ -42,7 +42,7 @@
/* Sidebar */
.sidebar {
width: 72px;
width: 90px;
background-color: var(--bg-surface-alt);
border-right: 1px solid var(--border-default);
box-shadow: none;
@@ -121,10 +121,6 @@
font-size: 11px;
font-weight: 500;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60px;
}
/* Main Content */
+115
View File
@@ -0,0 +1,115 @@
/*
Procurement Pane + Tab Bar Layout
(mirrors admin.css structure)
*/
/* Page container */
.procurement-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
/* ── Pane container ── */
.procurement-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Pane header (title + inline tabs) ── */
.procurement-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 24px 0;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.procurement-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.procurement-header-icon {
width: 18px;
height: 18px;
color: var(--text-secondary);
flex-shrink: 0;
}
.procurement-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Tab bar (inline within header) ── */
.procurement-header .tab-bar {
display: flex;
align-items: stretch;
gap: 0;
}
.procurement-header .tab-btn {
position: relative;
display: inline-flex;
align-items: center;
padding: 10px 14px 9px;
border: none;
background: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
text-decoration: none;
}
.procurement-header .tab-btn::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: transparent;
transition: background 0.15s;
}
.procurement-header .tab-btn:hover {
color: var(--text-primary);
}
.procurement-header .tab-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.procurement-header .tab-btn.active::after {
background: var(--input-focus-border);
}
/* ── Pane body (tab content area) ── */
.procurement-body {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
/*
Sales Opportunities Table
*/
.sales-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
.sales-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
.sales-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sales-header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.sales-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.sales-result-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.sales-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Search ── */
.sales-search-bar {
position: relative;
width: 280px;
}
.sales-search-bar input {
width: 100%;
padding: 9px 34px 9px 38px;
border: 1px solid var(--input-border);
border-radius: 8px;
font-size: 14px;
outline: none;
background: var(--input-bg);
color: var(--input-text);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.sales-search-bar input::placeholder {
color: var(--input-placeholder);
}
.sales-search-bar input:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px var(--input-focus-ring);
background: var(--input-focus-bg);
}
.sales-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-faint);
pointer-events: none;
}
.sales-search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition:
color 0.15s,
background 0.15s;
}
.sales-search-clear:hover {
color: var(--input-text);
background: var(--nav-hover-bg);
}
/* ── Filter button ── */
.sales-filter-wrap {
position: relative;
}
.sales-filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s,
color 0.15s,
background 0.15s;
white-space: nowrap;
}
.sales-filter-btn:hover {
border-color: var(--input-focus-border);
color: var(--text-primary);
}
.sales-filter-btn.has-filters {
border-color: var(--accent-color, #0066cc);
color: var(--accent-color, #0066cc);
}
.sales-filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent-color, #0066cc);
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.sales-filter-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
min-width: 220px;
padding: 8px 4px;
border: 1px solid var(--card-border);
border-radius: 8px;
background: var(--bg-surface);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.sales-filter-option {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 5px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.sales-filter-option:hover {
background: var(--card-hover-bg);
}
.sales-filter-option input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent-color, #0066cc);
cursor: pointer;
flex-shrink: 0;
}
.sales-body {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 16px 24px 8px;
}
.sales-table-wrap {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--card-border);
border-radius: 10px;
}
.sales-loading-overlay {
position: absolute;
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
backdrop-filter: blur(2px);
}
.sales-spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 4px solid var(--spinner-track);
border-top-color: var(--spinner-accent);
animation: sales-spin 0.7s linear infinite;
}
@keyframes sales-spin {
to {
transform: rotate(360deg);
}
}
.sales-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
min-height: 200px;
}
/* ── Table ── */
.sales-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.sales-table thead {
position: sticky;
top: 0;
z-index: 5;
background: var(--bg-inset);
}
.sales-table th {
padding: 10px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
.sales-table td {
padding: 12px 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.sales-table tbody tr:last-child td {
border-bottom: none;
}
.sales-row {
transition: background 0.15s;
}
.sales-row:hover {
background: var(--card-hover-bg);
}
.sales-row.closed-row {
opacity: 0.65;
}
/* ── Column widths ── */
.col-opportunity {
min-width: 220px;
}
.col-company {
min-width: 160px;
}
.col-stage,
.col-status,
.col-priority {
min-width: 120px;
}
.col-owner {
min-width: 150px;
}
.col-close,
.col-updated {
min-width: 120px;
white-space: nowrap;
}
.sales-opportunity {
display: flex;
flex-direction: column;
gap: 2px;
}
.opp-name {
font-weight: 600;
color: var(--text-primary);
}
.opp-meta {
font-size: 11px;
color: var(--text-muted);
}
.mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.sales-status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.sales-status-badge.status-open {
background: var(--status-active-bg, #dcfce7);
color: var(--status-active-color, #16a34a);
}
.sales-status-badge.status-closed {
background: var(--status-inactive-bg, #fee2e2);
color: var(--status-inactive-color, #dc2626);
}
.sales-priority {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.sales-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px 18px;
flex-shrink: 0;
}
.sales-page-info {
font-size: 13px;
color: var(--text-secondary);
}
.sales-pagination {
display: flex;
align-items: center;
gap: 4px;
}
.sales-page-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.sales-page-btn:hover:not(:disabled) {
background: var(--hover-bg);
border-color: var(--input-focus-border);
}
.sales-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sales-page-btn.active {
background: var(--accent-color, #0066cc);
color: #fff;
border-color: var(--accent-color, #0066cc);
}
.sales-page-ellipsis {
padding: 0 4px;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 1024px) {
.sales-search-bar {
width: 220px;
}
}
@media (max-width: 768px) {
.sales-header-actions {
width: 100%;
justify-content: space-between;
}
.sales-search-bar {
flex: 1;
}
.sales-footer {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}