9145ea5ba4
- Show fully/partially cancelled products in forecast summary table - Add cancellation KPI card with full/partial breakdown - Fully cancelled rows: strikethrough + reduced opacity + red badge - Partially cancelled rows: amber border + badge + effective/total qty - Add productSequence prop to ProductsTab for custom ordering - Fall back to CW sequenceNumber when no productSequence set - Add productSequence field to SalesOpportunity interface
244 lines
7.2 KiB
Svelte
244 lines
7.2 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";
|
|
|
|
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} {products} />
|
|
{:else if activeTab === "Products"}
|
|
<ProductsTab
|
|
{products}
|
|
accessToken={data.accessToken}
|
|
{opportunityId}
|
|
productSequence={opportunity?.productSequence ?? null}
|
|
/>
|
|
{: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>
|