feat: add procurement and sales sections
This commit is contained in:
+125
-3
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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, 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");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user