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
+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>