359 lines
11 KiB
Svelte
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>
|