feat: add procurement and sales sections
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user