feat: sales opportunity detail, procurement filters, permission resilience

- Add sales opportunity detail page with tabs (overview, notes, contacts, products, forecasts, activity)
- Add sales note CRUD endpoints (create, update, delete) with server routes
- Add opportunity types, contacts, product sequencing, and refresh API methods
- Add AddProductModal component for catalog browsing
- Update procurement.fetchMany to accept CatalogItemFilters object
- Add procurement.fetchCategories and procurement.fetchFilters endpoints
- Add resilient permission check (no-token returns all-true with __checkFailed)
- Parallelize company detail data fetches for performance
- Remove stale console.log statements across modules
- Add comprehensive unit tests for all new API methods and permission edge cases
This commit is contained in:
2026-03-01 13:08:58 -06:00
parent 27755d4a00
commit 4bec198db6
30 changed files with 10810 additions and 83 deletions
@@ -0,0 +1,242 @@
<script lang="ts">
import "../../../../styles/sales/opportunitydetail.css";
import { onMount } from "svelte";
import { invalidateAll } from "$app/navigation";
import type { PageData } from "./types";
// Tab components
import OpportunitySidebar from "./components/OpportunitySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import NotesTab from "./components/NotesTab.svelte";
import ContactsTab from "./components/ContactsTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
import ProductsTab from "./components/ProductsTab.svelte";
export let data: PageData;
$: opportunity = data.opportunity;
$: opportunityId = data.opportunityId;
$: notes = data.notes;
$: contacts = data.contacts;
$: products = data.products;
$: permissions = data.permissions;
// Mobile detection
let isMobile = false;
function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
}
onMount(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
// Tab navigation
const tabs = [
"Overview",
"Products",
"Notes",
"Contacts",
"Activity",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Mobile nav state
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
activeTab = tab;
mobileActiveTab = tab;
}
function mobileBack() {
mobileActiveTab = null;
}
</script>
<svelte:head>
<title>{opportunity?.name ?? "Opportunity"} — Project Optima</title>
</svelte:head>
<div class="opportunity-detail-page">
<!-- Left pane — Opportunity overview -->
<OpportunitySidebar {opportunity} {isMobile} {mobileActiveTab} />
<!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each tabs as tab}
<button
class="mobile-nav-item"
on:click={() => selectMobileTab(tab)}
type="button"
>
<span class="mobile-nav-icon">
{#if tab === "Products"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
{:else if tab === "Notes"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/><polyline points="14 2 14 8 20 8" /><line
x1="16"
y1="13"
x2="8"
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" />
</svg>
{:else if tab === "Contacts"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{/if}
</span>
<span class="mobile-nav-label">{tab}</span>
{#if tab === "Products" && products.length > 0}
<span class="mobile-nav-badge">{products.length}</span>
{/if}
{#if tab === "Notes" && notes.length > 0}
<span class="mobile-nav-badge">{notes.length}</span>
{/if}
{#if tab === "Contacts" && contacts.length > 0}
<span class="mobile-nav-badge">{contacts.length}</span>
{/if}
<svg
class="mobile-nav-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/each}
</div>
{/if}
<!-- Right pane -->
<div
class="opportunity-detail-right"
class:mobile-hidden={isMobile && mobileActiveTab === null}
>
<!-- Mobile content header with back button -->
{#if isMobile && mobileActiveTab !== null}
<div class="mobile-content-header">
<button
class="mobile-back-btn"
on:click={mobileBack}
type="button"
aria-label="Back to menu"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
</div>
{/if}
<div class="tab-bar" role="tablist">
{#each tabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{tab}
{#if tab === "Products" && products.length > 0}
<span class="tab-count-badge">{products.length}</span>
{/if}
{#if tab === "Notes" && notes.length > 0}
<span class="tab-count-badge">{notes.length}</span>
{/if}
{#if tab === "Contacts" && contacts.length > 0}
<span class="tab-count-badge">{contacts.length}</span>
{/if}
</button>
{/each}
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab {opportunity} {notes} {contacts} />
{:else if activeTab === "Products"}
<ProductsTab
{products}
accessToken={data.accessToken}
{opportunityId}
/>
{:else if activeTab === "Notes"}
<NotesTab
{notes}
{permissions}
{opportunityId}
on:notesChanged={() => {
invalidateAll();
}}
/>
{:else if activeTab === "Contacts"}
<ContactsTab {contacts} />
{:else if activeTab === "Activity"}
<ActivityTab />
{/if}
</div>
</div>
</div>