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,62 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
opportunity: null,
notes: [],
contacts: [],
products: [],
accessToken: null,
permissions: {} as PermissionMap,
};
}
try {
const [
opportunityResult,
notesResult,
contactsResult,
productsResult,
permissions,
] = await Promise.all([
optima.sales.fetchOne(accessToken, params.id),
optima.sales
.fetchNotes(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.sales
.fetchContacts(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.sales
.fetchProducts(accessToken, params.id)
.catch(() => ({ data: [] })),
checkPermissions(accessToken, [
"sales.opportunity.fetch",
"sales.opportunity.refresh",
"sales.opportunity.note.create",
"sales.opportunity.note.update",
"sales.opportunity.note.delete",
]),
]);
const opportunity = opportunityResult?.data ?? null;
const products = productsResult?.data ?? [];
console.log("[Products]", JSON.stringify(products, null, 2));
return {
opportunity,
opportunityId: params.id,
notes: notesResult?.data ?? [],
contacts: contactsResult?.data ?? [],
products,
accessToken,
permissions,
};
} catch (err) {
handleApiError(err);
}
};
@@ -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>
@@ -0,0 +1,21 @@
<script lang="ts">
</script>
<div class="activity-tab">
<div class="overview-section">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
</svg>
Recent Activity
</h3>
<p class="overview-placeholder">Activity feed coming soon.</p>
</div>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { OpportunityContact } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let contacts: OpportunityContact[];
</script>
<div class="contacts-tab">
{#if contacts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No contacts associated with this opportunity" />
</div>
{:else}
<div class="contacts-grid">
{#each contacts as c (c.id)}
<div class="contact-card">
<div class="contact-avatar">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
</div>
<div class="contact-info">
<span class="contact-name">{c.contact?.name ?? "Unknown"}</span>
{#if c.role?.name}
<span class="contact-role">{c.role.name}</span>
{/if}
{#if c.company?.name}
<span class="contact-company">{c.company.name}</span>
{/if}
{#if c.notes}
<span class="contact-notes">{c.notes}</span>
{/if}
</div>
{#if c.referralFlag}
<span class="contact-badge referral">Referral</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import type { OpportunityForecast } from "../types";
import { formatCurrency, formatDate } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let forecasts: OpportunityForecast[];
</script>
<div class="forecasts-tab">
{#if forecasts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No forecast data available" />
</div>
{:else}
<div class="forecasts-table-wrap">
<table class="forecasts-table">
<thead>
<tr>
<th>Type</th>
<th>Month</th>
<th class="num">Revenue</th>
<th class="num">Cost</th>
<th class="num">Margin</th>
<th class="num">Probability</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each forecasts as f (f.id)}
<tr>
<td>{f.forecastType ?? "—"}</td>
<td>{formatDate(f.forecastMonth)}</td>
<td class="num">{formatCurrency(f.revenue)}</td>
<td class="num">{formatCurrency(f.cost)}</td>
<td class="num">
{f.revenue != null && f.cost != null
? formatCurrency(f.revenue - f.cost)
: "—"}
</td>
<td class="num">
{f.forecastPercentage != null
? `${f.forecastPercentage}%`
: "—"}
</td>
<td>
<span
class="forecast-status-badge"
class:included={f.includedFlag}
>
{f.status?.name ?? "—"}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Revenue summary -->
<div class="forecasts-summary">
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Revenue</span>
<span class="forecast-summary-value">
{formatCurrency(
forecasts.reduce((sum, f) => sum + (f.revenue ?? 0), 0),
)}
</span>
</div>
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Cost</span>
<span class="forecast-summary-value">
{formatCurrency(forecasts.reduce((sum, f) => sum + (f.cost ?? 0), 0))}
</span>
</div>
<div class="forecast-summary-item">
<span class="forecast-summary-label">Total Margin</span>
<span class="forecast-summary-value">
{formatCurrency(
forecasts.reduce(
(sum, f) => sum + ((f.revenue ?? 0) - (f.cost ?? 0)),
0,
),
)}
</span>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,531 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { OpportunityNote } from "../types";
import { noteAuthorInitials, formatDateTime } from "../types";
import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let notes: OpportunityNote[];
export let permissions: PermissionMap;
export let opportunityId: string;
$: console.log("Notes data:", JSON.stringify(notes, null, 2));
$: if (notes.length > 0)
console.log(
"First note keys:",
Object.keys(notes[0]),
"dateEntered:",
notes[0].dateEntered,
);
const dispatch = createEventDispatcher();
$: canCreate = permissions["sales.opportunity.note.create"] === true;
$: canUpdate = permissions["sales.opportunity.note.update"] === true;
$: canDelete = permissions["sales.opportunity.note.delete"] === true;
// ── Compose state ──
let composing = false;
let composeText = "";
let composeFlagged = false;
let composeSaving = false;
let composeError = "";
// ── Edit state ──
let editingNoteId: number | null = null;
let editText = "";
let editFlagged = false;
let editSaving = false;
let editError = "";
// ── Delete state ──
let deletingNoteId: number | null = null;
let deleteLoading = false;
let deleteError = "";
// ── Menu state ──
let openMenuId: number | null = null;
function toggleMenu(id: number) {
openMenuId = openMenuId === id ? null : id;
}
function handleMenuClickOutside(e: MouseEvent) {
if (openMenuId === null) return;
const target = e.target as HTMLElement;
if (target.closest(".note-menu-wrap")) return;
openMenuId = null;
}
// ── Compose ──
function startCompose() {
composing = true;
composeText = "";
composeFlagged = false;
composeError = "";
}
function cancelCompose() {
composing = false;
composeText = "";
composeFlagged = false;
composeError = "";
}
async function submitNote() {
if (!composeText.trim()) return;
composeSaving = true;
composeError = "";
try {
const res = await fetch(`/sales/opportunity/${opportunityId}/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: composeText.trim(),
flagged: composeFlagged,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "Failed to create note");
}
composing = false;
composeText = "";
composeFlagged = false;
dispatch("notesChanged");
} catch (err: unknown) {
composeError =
err instanceof Error ? err.message : "Failed to create note";
} finally {
composeSaving = false;
}
}
// ── Edit ──
function startEdit(note: OpportunityNote) {
editingNoteId = note.id;
editText = note.text ?? "";
editFlagged = note.flagged ?? false;
editError = "";
openMenuId = null;
}
function cancelEdit() {
editingNoteId = null;
editText = "";
editFlagged = false;
editError = "";
}
async function submitEdit() {
if (editingNoteId === null || !editText.trim()) return;
editSaving = true;
editError = "";
try {
const res = await fetch(
`/sales/opportunity/${opportunityId}/notes/${editingNoteId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: editText.trim(),
flagged: editFlagged,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "Failed to update note");
}
editingNoteId = null;
editText = "";
editFlagged = false;
dispatch("notesChanged");
} catch (err: unknown) {
editError = err instanceof Error ? err.message : "Failed to update note";
} finally {
editSaving = false;
}
}
// ── Delete ──
function confirmDelete(noteId: number) {
deletingNoteId = noteId;
deleteError = "";
openMenuId = null;
}
function cancelDelete() {
deletingNoteId = null;
deleteError = "";
}
async function executeDelete() {
if (deletingNoteId === null) return;
deleteLoading = true;
deleteError = "";
try {
const res = await fetch(
`/sales/opportunity/${opportunityId}/notes/${deletingNoteId}`,
{ method: "DELETE" },
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "Failed to delete note");
}
deletingNoteId = null;
dispatch("notesChanged");
} catch (err: unknown) {
deleteError =
err instanceof Error ? err.message : "Failed to delete note";
} finally {
deleteLoading = false;
}
}
function handleComposeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitNote();
}
if (e.key === "Escape") {
cancelCompose();
}
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitEdit();
}
if (e.key === "Escape") {
cancelEdit();
}
}
</script>
<svelte:window on:click={handleMenuClickOutside} />
<div class="notes-tab">
<!-- Header bar -->
<div class="notes-header">
<div class="notes-header-left">
<span class="notes-count">
{notes.length} note{notes.length === 1 ? "" : "s"}
</span>
</div>
{#if canCreate && !composing}
<button class="notes-add-btn" on:click={startCompose} type="button">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Note
</button>
{/if}
</div>
<!-- Compose area -->
{#if composing}
<div class="note-compose">
<textarea
class="note-compose-textarea"
placeholder="Write a note…"
bind:value={composeText}
on:keydown={handleComposeKeydown}
rows="3"
disabled={composeSaving}
></textarea>
<div class="note-compose-footer">
<label class="note-flag-toggle">
<input
type="checkbox"
bind:checked={composeFlagged}
disabled={composeSaving}
/>
<svg
viewBox="0 0 24 24"
fill={composeFlagged ? "currentColor" : "none"}
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class="flag-icon"
class:flagged={composeFlagged}
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
Flag
</label>
<div class="note-compose-actions">
{#if composeError}
<span class="note-error">{composeError}</span>
{/if}
<button
class="note-btn-cancel"
on:click={cancelCompose}
disabled={composeSaving}
type="button"
>
Cancel
</button>
<button
class="note-btn-save"
on:click={submitNote}
disabled={composeSaving || !composeText.trim()}
type="button"
>
{#if composeSaving}
Saving…
{:else}
Save
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Notes list -->
{#if notes.length === 0 && !composing}
<div class="tab-empty">
<NoResultsMonkey message="No notes yet" />
</div>
{:else}
<div class="notes-list">
{#each notes as note (note.id)}
{#if editingNoteId === note.id}
<!-- Inline edit -->
<div class="note-card editing">
<textarea
class="note-edit-textarea"
bind:value={editText}
on:keydown={handleEditKeydown}
rows="3"
disabled={editSaving}
></textarea>
<div class="note-compose-footer">
<label class="note-flag-toggle">
<input
type="checkbox"
bind:checked={editFlagged}
disabled={editSaving}
/>
<svg
viewBox="0 0 24 24"
fill={editFlagged ? "currentColor" : "none"}
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class="flag-icon"
class:flagged={editFlagged}
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
Flag
</label>
<div class="note-compose-actions">
{#if editError}
<span class="note-error">{editError}</span>
{/if}
<button
class="note-btn-cancel"
on:click={cancelEdit}
disabled={editSaving}
type="button"
>
Cancel
</button>
<button
class="note-btn-save"
on:click={submitEdit}
disabled={editSaving || !editText.trim()}
type="button"
>
{#if editSaving}
Saving…
{:else}
Update
{/if}
</button>
</div>
</div>
</div>
{:else}
<!-- Read-only note card -->
<div class="note-card" class:flagged={note.flagged}>
<div class="note-card-header">
<div class="note-header-left">
{#if canUpdate || canDelete}
<div class="note-menu-wrap">
<button
class="note-menu-btn"
on:click|stopPropagation={() => toggleMenu(note.id)}
type="button"
aria-label="Note actions"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="14"
height="14"
>
<circle cx="8" cy="3" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="13" r="1.5" />
</svg>
</button>
{#if openMenuId === note.id}
<div class="note-menu-dropdown">
{#if canUpdate}
<button
class="note-menu-item"
on:click={() => startEdit(note)}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit
</button>
{/if}
{#if canDelete}
<button
class="note-menu-item danger"
on:click={() => confirmDelete(note.id)}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
</svg>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
{#if note.type?.name}
<span class="note-type-badge">{note.type.name}</span>
{/if}
{#if note.flagged}
<svg
class="note-flag-icon"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
/><line x1="4" y1="22" x2="4" y2="15" />
</svg>
{/if}
</div>
<div class="note-header-right">
<div class="note-author-info">
<span class="note-author-name"
>{note.enteredBy?.name ?? "Unknown"}</span
>
<span class="note-timestamp"
>{formatDateTime(note.dateEntered)}</span
>
</div>
<div
class="note-avatar"
title={note.enteredBy?.name ?? "Unknown"}
>
{noteAuthorInitials(note.enteredBy?.name)}
</div>
</div>
</div>
<div class="note-card-body">
<p class="note-text">{note.text ?? ""}</p>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Delete confirmation modal -->
{#if deletingNoteId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="note-delete-overlay" on:click={cancelDelete} role="presentation">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="note-delete-modal"
on:click|stopPropagation
role="dialog"
aria-modal="true"
tabindex="-1"
>
<h4 class="note-delete-title">Delete Note</h4>
<p class="note-delete-msg">
Are you sure you want to delete this note? This action cannot be undone.
</p>
{#if deleteError}
<p class="note-error">{deleteError}</p>
{/if}
<div class="note-delete-actions">
<button
class="note-btn-cancel"
on:click={cancelDelete}
disabled={deleteLoading}
type="button"
>
Cancel
</button>
<button
class="note-btn-delete"
on:click={executeDelete}
disabled={deleteLoading}
type="button"
>
{#if deleteLoading}
Deleting…
{:else}
Delete
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,285 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import { statusColorClass } from "../types";
export let opportunity: SalesOpportunity | null;
export let isMobile: boolean;
export let mobileActiveTab: string | null;
// Use site address first (more specific), fall back to company address
$: address =
opportunity?.site?.address ?? opportunity?.company?.cw_Data?.address;
// Find the matching contact in allContacts for phone/email
$: allContacts = opportunity?.company?.cw_Data?.allContacts ?? [];
$: matchedContact = opportunity?.contact?.id
? (allContacts.find((c) => c.cwId === opportunity?.contact?.id) ??
allContacts[0])
: (allContacts[0] ?? null);
$: contactPhone =
matchedContact?.phone ?? opportunity?.site?.phoneNumber ?? null;
$: contactEmail = matchedContact?.email ?? null;
function formatPhone(phone: string): string {
const digits = phone.replace(/\D/g, "");
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
if (digits.length === 11 && digits[0] === "1") {
return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
}
return phone;
}
</script>
<div
class="opportunity-detail-left"
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
>
<div class="opp-sidebar">
<button
class="back-btn"
on:click={() => goto("/sales")}
aria-label="Back to sales"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{#if opportunity}
<!-- ── Headline ── -->
<div class="opp-headline">
<h3 class="opp-name">{opportunity.name}</h3>
<div class="opp-meta-row">
{#if opportunity.cwOpportunityId}
<span class="opp-number">#{opportunity.cwOpportunityId}</span>
{/if}
{#if opportunity.status}
<span class="opp-status-badge {statusColorClass(opportunity)}">
{opportunity.closedFlag ? "Closed" : opportunity.status.name}
</span>
{/if}
{#if opportunity.type?.name}
<span class="opp-type-badge">{opportunity.type.name}</span>
{/if}
</div>
</div>
<!-- ── Byline (Sales Rep) ── -->
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
<div class="opp-byline">
{#if opportunity.primarySalesRep?.name}
<div class="opp-byline-rep">
<div class="opp-byline-avatar">
{opportunity.primarySalesRep.name.charAt(0).toUpperCase()}
</div>
<div class="opp-byline-info">
<span class="opp-byline-name"
>{opportunity.primarySalesRep.name}</span
>
<span class="opp-byline-role">Primary Rep</span>
</div>
</div>
{/if}
{#if opportunity.secondarySalesRep?.name}
<div class="opp-byline-rep">
<div class="opp-byline-avatar secondary">
{opportunity.secondarySalesRep.name.charAt(0).toUpperCase()}
</div>
<div class="opp-byline-info">
<span class="opp-byline-name"
>{opportunity.secondarySalesRep.name}</span
>
<span class="opp-byline-role">Secondary Rep</span>
</div>
</div>
{/if}
</div>
{/if}
<div class="opp-sidebar-divider"></div>
<div class="opp-sidebar-info">
<!-- ── Company ── -->
{#if opportunity.company?.name}
<a
href="/companies/{opportunity.company.id}"
class="opp-info-row opp-info-row-link"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opp-info-icon"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path
d="M16 3h-8l-2 4h12z"
/>
</svg>
<div class="opp-info-content">
<span class="opp-info-label">Company</span>
<span class="opp-info-value">{opportunity.company.name}</span>
</div>
<svg
class="opp-info-arrow"
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>
</a>
{/if}
<!-- ── Address / Site ── -->
{#if address || opportunity.site?.name}
<div class="opp-info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opp-info-icon"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" /><circle
cx="12"
cy="10"
r="3"
/>
</svg>
<div class="opp-info-content">
{#if opportunity.site?.name}
<span class="opp-info-label">{opportunity.site.name}</span>
{:else}
<span class="opp-info-label">Address</span>
{/if}
{#if address}
<span class="opp-info-value">
{#if address.line1}{address.line1}<br />{/if}
{#if address.line2}{address.line2}<br />{/if}
{#if address.city || address.state || address.zip}
{[address.city, address.state]
.filter(Boolean)
.join(", ")}{address.zip ? ` ${address.zip}` : ""}
{/if}
</span>
{:else}
<span class="opp-info-muted">No address on file</span>
{/if}
</div>
</div>
{/if}
<!-- ── Contact ── -->
{#if opportunity.contact?.name || matchedContact}
<div class="opp-info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opp-info-icon"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
<div class="opp-info-content">
<span class="opp-info-label">Contact</span>
<span class="opp-info-value">
{opportunity.contact?.name ??
[matchedContact?.firstName, matchedContact?.lastName]
.filter(Boolean)
.join(" ") ??
"\u2014"}
</span>
{#if matchedContact?.title}
<span class="opp-info-sub">{matchedContact.title}</span>
{/if}
{#if contactPhone}
<a href="tel:{contactPhone}" class="opp-info-phone">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
{formatPhone(contactPhone)}
</a>
{/if}
{#if contactEmail}
<a href="mailto:{contactEmail}" class="opp-info-email">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
/><polyline points="22,6 12,13 2,6" />
</svg>
{contactEmail}
</a>
{/if}
</div>
</div>
{/if}
<!-- ── Description ── -->
{#if opportunity.notes}
<div class="opp-desc-section">
<div class="opp-desc-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<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>
<span class="opp-desc-label">Description</span>
</div>
<div class="opp-desc-card">
<p class="opp-desc-text">{opportunity.notes}</p>
</div>
</div>
{/if}
</div>
{:else}
<div class="opp-sidebar-empty">
<p>Opportunity not found.</p>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,282 @@
<script lang="ts">
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { OpportunityNote, OpportunityContact } from "../types";
import { formatDate, formatCurrency, statusColorClass } from "../types";
export let opportunity: SalesOpportunity | null;
export let notes: OpportunityNote[];
export let contacts: OpportunityContact[];
// Timeline entries — built dynamically from available dates
$: timeline = [
opportunity?.dateBecameLead
? { label: "Became Lead", date: opportunity.dateBecameLead, icon: "lead" }
: null,
opportunity?.pipelineChangeDate
? {
label: "Pipeline Changed",
date: opportunity.pipelineChangeDate,
icon: "pipeline",
}
: null,
opportunity?.expectedCloseDate
? {
label: "Expected Close",
date: opportunity.expectedCloseDate,
icon: "target",
}
: null,
opportunity?.closedDate
? { label: "Closed", date: opportunity.closedDate, icon: "closed" }
: null,
].filter(Boolean) as { label: string; date: string; icon: string }[];
// Days until expected close
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || opportunity?.closedFlag) return null;
const diff = Math.ceil(
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
(1000 * 60 * 60 * 24),
);
return diff;
})();
</script>
<div class="overview-tab">
<!-- ═══ Pipeline Banner ═══ -->
<div class="ov-pipeline-banner">
<div class="ov-pipeline-stages">
{#if opportunity?.stage?.name}
<div class="ov-pipeline-chip stage">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
{opportunity.stage.name}
</div>
{/if}
{#if opportunity?.priority?.name}
<div class="ov-pipeline-chip priority">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
{opportunity.priority.name}
</div>
{/if}
{#if opportunity?.rating?.name}
<div class="ov-pipeline-chip rating">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{opportunity.rating.name}
</div>
{/if}
</div>
{#if daysUntilClose !== null}
<div
class="ov-close-countdown"
class:overdue={daysUntilClose < 0}
class:soon={daysUntilClose >= 0 && daysUntilClose <= 14}
>
<span class="ov-close-number">{Math.abs(daysUntilClose)}</span>
<span class="ov-close-unit">
{daysUntilClose < 0
? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
: `day${daysUntilClose !== 1 ? "s" : ""} to close`}
</span>
</div>
{/if}
</div>
<!-- ═══ Deal Metrics ═══ -->
<div class="ov-metrics-row">
{#if opportunity?.expectedCloseDate}
<div class="ov-metric-card">
<div class="ov-metric-icon close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Expected Close</span>
<span class="ov-metric-value"
>{formatDate(opportunity.expectedCloseDate)}</span
>
</div>
</div>
{/if}
{#if opportunity?.totalSalesTax != null}
<div class="ov-metric-card">
<div class="ov-metric-icon money">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<line x1="12" y1="1" x2="12" y2="23" /><path
d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Sales Tax</span>
<span class="ov-metric-value"
>{formatCurrency(opportunity.totalSalesTax)}</span
>
</div>
</div>
{/if}
{#if opportunity?.source}
<div class="ov-metric-card">
<div class="ov-metric-icon source">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Source</span>
<span class="ov-metric-value">{opportunity.source}</span>
</div>
</div>
{/if}
<div class="ov-metric-card">
<div class="ov-metric-icon activity">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<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>
</div>
<div class="ov-metric-body">
<span class="ov-metric-label">Activity</span>
<span class="ov-metric-value"
>{notes.length} notes · {contacts.length} contacts</span
>
</div>
</div>
</div>
<!-- ═══ Timeline ═══ -->
{#if timeline.length > 0}
<div class="ov-section">
<h3 class="ov-section-title">Timeline</h3>
<div class="ov-timeline">
{#each timeline as entry, i}
<div class="ov-timeline-item" class:last={i === timeline.length - 1}>
<div class="ov-timeline-dot"></div>
<div class="ov-timeline-content">
<span class="ov-timeline-label">{entry.label}</span>
<span class="ov-timeline-date">{formatDate(entry.date)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- ═══ Details ═══ -->
<div class="ov-section">
<h3 class="ov-section-title">Details</h3>
<div class="ov-details-grid">
{#if opportunity?.cwOpportunityId}
<div class="ov-detail">
<span class="ov-detail-label">CW Opportunity ID</span>
<span class="ov-detail-value mono">{opportunity.cwOpportunityId}</span
>
</div>
{/if}
{#if opportunity?.customerPO}
<div class="ov-detail">
<span class="ov-detail-label">Customer PO</span>
<span class="ov-detail-value">{opportunity.customerPO}</span>
</div>
{/if}
{#if opportunity?.campaign}
<div class="ov-detail">
<span class="ov-detail-label">Campaign</span>
<span class="ov-detail-value">{opportunity.campaign}</span>
</div>
{/if}
{#if opportunity?.location?.name}
<div class="ov-detail">
<span class="ov-detail-label">Location</span>
<span class="ov-detail-value">{opportunity.location.name}</span>
</div>
{/if}
{#if opportunity?.department?.name}
<div class="ov-detail">
<span class="ov-detail-label">Department</span>
<span class="ov-detail-value">{opportunity.department.name}</span>
</div>
{/if}
{#if opportunity?.closedBy}
<div class="ov-detail">
<span class="ov-detail-label">Closed By</span>
<span class="ov-detail-value">{opportunity.closedBy}</span>
</div>
{/if}
<div class="ov-detail">
<span class="ov-detail-label">Last Synced</span>
<span class="ov-detail-value"
>{formatDate(opportunity?.cwLastUpdated)}</span
>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** POST /sales/opportunity/[id]/notes — create a note */
export const POST: RequestHandler = async ({ params, request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const body = await request.json();
if (!body.text?.trim()) throw error(400, "Note text is required");
try {
const result = await optima.sales.createNote(accessToken, params.id, {
text: body.text.trim(),
flagged: body.flagged ?? false,
});
return json(result, { status: 201 });
} catch (err: unknown) {
console.error("Failed to create note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to create note");
}
};
@@ -0,0 +1,63 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** PATCH /sales/opportunity/[id]/notes/[noteId] — update a note */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const noteId = Number(params.noteId);
if (isNaN(noteId)) throw error(400, "Invalid note ID");
const body = await request.json();
if (!body.text?.trim() && body.flagged === undefined) {
throw error(400, "At least text or flagged must be provided");
}
const payload: { text?: string; flagged?: boolean } = {};
if (body.text?.trim()) payload.text = body.text.trim();
if (body.flagged !== undefined) payload.flagged = body.flagged;
try {
const result = await optima.sales.updateNote(
accessToken,
params.id,
noteId,
payload,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to update note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to update note");
}
};
/** DELETE /sales/opportunity/[id]/notes/[noteId] — delete a note */
export const DELETE: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const noteId = Number(params.noteId);
if (isNaN(noteId)) throw error(400, "Invalid note ID");
try {
const result = await optima.sales.deleteNote(
accessToken,
params.id,
noteId,
);
return json(result);
} catch (err: unknown) {
console.error("Failed to delete note:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to delete note");
}
};
+156
View File
@@ -0,0 +1,156 @@
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
export interface OpportunityForecast {
id: number;
forecastType?: string;
forecastMonth?: string;
revenue?: number;
cost?: number;
forecastPercentage?: number;
status?: { id: number; name: string };
includedFlag?: boolean;
linkedFlag?: boolean;
recurringFlag?: boolean;
[key: string]: unknown;
}
export interface NoteAuthor {
id: string;
identifier: string;
name: string;
cwMemberId?: number;
}
export interface OpportunityNote {
id: number;
text?: string;
type?: { id: number; name: string };
flagged?: boolean;
enteredBy?: NoteAuthor | null;
dateEntered?: string;
_info?: { lastUpdated?: string; updatedBy?: string };
[key: string]: unknown;
}
export function noteAuthorInitials(name?: string): string {
if (!name) return "?";
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
}
export function formatDateTime(dateStr?: string | null): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return "";
}
}
export interface OpportunityProduct {
id: number;
forecastDescription?: string;
productDescription?: string;
productClass?: string;
forecastType?: string;
quantity?: number;
revenue?: number;
cost?: number;
margin?: number;
profit?: number;
percentage?: number;
status?: { id: number; name: string };
catalogItem?: { id: number; identifier: string };
opportunity?: { id: number; name: string };
includeFlag?: boolean;
linkFlag?: boolean;
recurringFlag?: boolean;
taxableFlag?: boolean;
cancelled?: boolean;
cancellationType?: "full" | "partial" | null;
quantityCancelled?: number;
cancelledReason?: string | null;
cancelledDate?: string | null;
recurringRevenue?: number;
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
subNumber?: number;
cwLastUpdated?: string;
cwUpdatedBy?: string;
onHand?: number | null;
inStock?: boolean | null;
[key: string]: unknown;
}
export interface OpportunityContact {
id: number;
contact?: { id: number; name: string };
company?: { id: number; identifier?: string; name: string };
role?: { id: number; name: string };
notes?: string;
referralFlag?: boolean;
[key: string]: unknown;
}
export interface PageData {
opportunity: SalesOpportunity | null;
opportunityId: string;
notes: OpportunityNote[];
contacts: OpportunityContact[];
products: OpportunityProduct[];
accessToken: string | null;
permissions: PermissionMap;
}
export function opportunityInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
export function formatCurrency(amount?: number | null): string {
if (amount == null) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(amount);
}
export function statusColorClass(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) return "status-closed";
const name = opportunity.status?.name?.toLowerCase();
if (!name) return "status-open";
if (name === "won") return "status-won";
if (name === "lost") return "status-lost";
if (name === "inactive") return "status-inactive";
return "status-open";
}