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:
@@ -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>
|
||||
Reference in New Issue
Block a user