Files
optima/src/routes/sales/opportunity/[id]/+page.svelte
T

359 lines
11 KiB
Svelte

<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";
import QuotesTab from "./components/QuotesTab.svelte";
export let data: PageData;
$: opportunity = data.opportunity;
$: opportunityId = data.opportunityId;
$: notes = data.notes;
$: contacts = data.contacts;
$: products = data.products;
$: quotes = data.quotes ?? [];
$: permissions = data.permissions;
let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null;
$: if (Array.isArray(opportunity?.productSequence)) {
localProductSequence = opportunity.productSequence;
}
// Mobile detection
let isMobile = false;
function checkMobile() {
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
}
onMount(() => {
checkMobile();
console.log("[OpportunityLoad] Description values:", {
description: (opportunity as Record<string, unknown> | null)?.description,
notes: opportunity?.notes ?? null,
name: opportunity?.name ?? null,
opportunityId,
});
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
// Tab navigation
const tabs = [
"Overview",
"Products",
"Quotes",
"Notes",
"Contacts",
"Activity",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Hide Quotes tab if user lacks fetch permission
$: visibleTabs = tabs.filter(
(t) =>
t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false,
);
// Track whether ProductsTab is in edit mode
let productsEditing = false;
/** Guard: block tab switch if ProductsTab has unsaved edits */
function guardedSetTab(tab: Tab) {
if (activeTab === tab) return;
if (productsEditing) {
if (
!confirm("You have unsaved product changes. Discard and switch tabs?")
) {
return;
}
productsEditing = false;
}
activeTab = tab;
}
// Product to auto-select when switching to Products tab
let pendingProductId: number | null = null;
function handleSelectProduct(e: CustomEvent<number>) {
pendingProductId = e.detail;
guardedSetTab("Products");
}
function handleSequenceSaved(e: CustomEvent<number[]>) {
localProductSequence = e.detail;
}
function handleProductsChanged(e: CustomEvent<PageData["products"]>) {
products = e.detail;
}
// Mobile nav state
let mobileActiveTab: Tab | null = null;
function selectMobileTab(tab: Tab) {
if (productsEditing) {
if (
!confirm("You have unsaved product changes. Discard and switch tabs?")
) {
return;
}
productsEditing = false;
}
activeTab = tab;
mobileActiveTab = tab;
}
function mobileBack() {
if (productsEditing) {
if (!confirm("You have unsaved product changes. Discard and go back?")) {
return;
}
productsEditing = false;
}
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}
{permissions}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
<!-- Mobile vertical nav menu -->
{#if isMobile && mobileActiveTab === null}
<div class="mobile-nav-menu">
{#each visibleTabs 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 if tab === "Quotes"}
<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="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</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}
{#if tab === "Quotes" && quotes.length > 0}
<span class="mobile-nav-badge">{quotes.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 visibleTabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => guardedSetTab(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}
{#if tab === "Quotes" && quotes.length > 0}
<span class="tab-count-badge">{quotes.length}</span>
{/if}
</button>
{/each}
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
<OverviewTab
{opportunity}
{notes}
{contacts}
{products}
on:selectProduct={handleSelectProduct}
/>
{:else if activeTab === "Products"}
<ProductsTab
{products}
accessToken={data.accessToken}
{opportunityId}
productSequence={localProductSequence}
initialProductId={pendingProductId}
{permissions}
bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
/>
{:else if activeTab === "Quotes"}
<QuotesTab
accessToken={data.accessToken}
opportunityId={data.opportunityId}
initialQuotes={quotes}
{permissions}
/>
{: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>