all the haul

This commit is contained in:
2026-04-07 23:56:31 +00:00
parent 87cce83030
commit 24f303355b
244 changed files with 33743 additions and 11249 deletions
@@ -1,5 +1,6 @@
import { Actions, redirect } from "@sveltejs/kit";
import { optima } from "$lib";
import { INTERNAL_API_URL } from "$env/static/private";
export const actions: Actions = {
login: async (event) => {
@@ -7,6 +8,7 @@ export const actions: Actions = {
const tokens = await optima.user.awaitAuthCallback(
data.get("callbackKey") as string,
INTERNAL_API_URL,
);
event.cookies.set("accessToken", tokens.accessToken, {
+30
View File
@@ -31,6 +31,14 @@
},
];
const personalNavItems = [
{
href: "/calendar",
label: "Calendar",
icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>',
},
];
const adminNavItem = {
href: "/admin",
label: "Admin",
@@ -115,6 +123,28 @@
</a>
{/each}
<hr class="nav-divider" />
{#each personalNavItems as item}
<a
href={item.href}
class="nav-item {isActive($page.url.pathname, item)
? 'active'
: ''}"
title={item.label}
>
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@html item.icon}
</svg>
<span class="nav-label">{item.label}</span>
</a>
{/each}
{#if canViewAdmin}
<hr class="nav-divider" />
<a
+16 -7
View File
@@ -37,13 +37,13 @@ export const load: PageServerLoad = async ({ locals }) => {
try {
const rolesResult = await optima.users.fetchRoles(
accessToken,
user.id,
user.id
);
return { ...user, roleDetails: rolesResult?.data ?? [] };
} catch {
return { ...user, roleDetails: [] };
}
}),
})
);
return {
@@ -66,21 +66,30 @@ export const actions: Actions = {
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const firstName = (formData.get("firstName") as string)?.trim();
const lastName = (formData.get("lastName") as string)?.trim();
const image = (formData.get("image") as string)?.trim() || undefined;
const rolesJson = (formData.get("roles") as string)?.trim();
const permissionsJson = (formData.get("permissions") as string)?.trim();
if (!id || !name) {
return fail(400, { message: "User ID and name are required." });
if (!id) {
return fail(400, { message: "User ID is required." });
}
const updates: {
name: string;
firstName?: string | null;
lastName?: string | null;
image?: string;
roles?: string[];
permissions?: string[];
} = { name, image };
} = { image };
if (formData.has("firstName")) {
updates.firstName = firstName || null;
}
if (formData.has("lastName")) {
updates.lastName = lastName || null;
}
if (rolesJson) {
try {
+12 -6
View File
@@ -29,13 +29,18 @@
$: users = data.users;
$: allRoles = data.roles;
function fullName(user: UserWithRoles): string {
const computed = `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim();
return computed || user.name || user.login || user.email;
}
// Search / filter
let searchQuery = "";
$: filteredUsers = users.filter((u) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
fullName(u).toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.login.toLowerCase().includes(q)
);
@@ -151,8 +156,9 @@
onCancel={cancelDelete}
handleEnhance={handleDeleteEnhance}
>
Are you sure you want to delete <strong>{userToDelete?.name}</strong>? This
action cannot be undone.
Are you sure you want to delete <strong
>{userToDelete ? fullName(userToDelete) : "this user"}</strong
>? This action cannot be undone.
</DeleteConfirmDialog>
<div class="admin-table-header">
@@ -211,15 +217,15 @@
{#if user.image}
<img
src={user.image}
alt={user.name}
alt={fullName(user)}
class="user-table-avatar"
/>
{:else}
<div class="user-table-avatar user-table-avatar-initials">
{initials(user.name)}
{initials(fullName(user))}
</div>
{/if}
<span class="user-table-name">{user.name}</span>
<span class="user-table-name">{fullName(user)}</span>
</div>
</td>
<td>
+23 -12
View File
@@ -20,6 +20,10 @@ const { mockOptima, mockCheckPermissions, mockHandleApiError, mockFail } =
})),
}));
vi.mock("$env/static/public", () => ({
PUBLIC_API_URL: "https://api.example.com",
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
@@ -102,15 +106,15 @@ describe("admin/users +page.server.ts", () => {
});
});
it("returns 400 when required fields are missing", async () => {
it("returns 400 when user ID is missing", async () => {
await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi.fn().mockResolvedValue(createFormData({ id: "u1" })),
formData: vi.fn().mockResolvedValue(createFormData({})),
},
} as any);
expect(mockFail).toHaveBeenCalledWith(400, {
message: "User ID and name are required.",
message: "User ID is required.",
});
});
@@ -120,14 +124,19 @@ describe("admin/users +page.server.ts", () => {
const result = await actions.updateUser({
locals: { session: { accessToken: "tok" } },
request: {
formData: vi
.fn()
.mockResolvedValue(createFormData({ id: "u1", name: "Updated" })),
formData: vi.fn().mockResolvedValue(
createFormData({
id: "u1",
firstName: "Updated",
lastName: "User",
})
),
},
} as any);
expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", {
name: "Updated",
firstName: "Updated",
lastName: "User",
image: undefined,
});
expect(result).toEqual({});
@@ -142,15 +151,17 @@ describe("admin/users +page.server.ts", () => {
formData: vi.fn().mockResolvedValue(
createFormData({
id: "u1",
name: "Updated",
firstName: "Updated",
lastName: "User",
roles: '["r1","r2"]',
}),
})
),
},
} as any);
expect(mockOptima.users.update).toHaveBeenCalledWith("tok", "u1", {
name: "Updated",
firstName: "Updated",
lastName: "User",
image: undefined,
roles: ["r1", "r2"],
});
@@ -163,9 +174,9 @@ describe("admin/users +page.server.ts", () => {
formData: vi.fn().mockResolvedValue(
createFormData({
id: "u1",
name: "Updated",
firstName: "Updated",
roles: "bad json",
}),
})
),
},
} as any);
@@ -21,7 +21,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
stage: body.stage || undefined,
status: body.status || undefined,
priority: body.priority || undefined,
rating: body.rating || undefined,
interest: body.interest || undefined,
primarySalesRep: body.primarySalesRep || undefined,
secondarySalesRep: body.secondarySalesRep || undefined,
company: body.company || undefined,
@@ -36,7 +36,7 @@ describe("POST /api/sales/opportunities", () => {
};
await expect(POST(event as any)).rejects.toEqual(
expect.objectContaining({ status: 401 }),
expect.objectContaining({ status: 401 })
);
});
@@ -51,7 +51,7 @@ describe("POST /api/sales/opportunities", () => {
};
await expect(POST(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
expect.objectContaining({ status: 400 })
);
});
@@ -64,7 +64,7 @@ describe("POST /api/sales/opportunities", () => {
};
await expect(POST(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
expect.objectContaining({ status: 400 })
);
});
@@ -93,7 +93,7 @@ describe("POST /api/sales/opportunities", () => {
stage: undefined,
status: undefined,
priority: undefined,
rating: undefined,
interest: undefined,
primarySalesRep: undefined,
secondarySalesRep: undefined,
company: undefined,
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { Calendar, DayGrid, TimeGrid, Interaction } from "@event-calendar/core";
import "@event-calendar/core/index.css";
import "../../styles/calendar.css";
type CalendarEvent = {
id: string;
start: string | Date;
end: string | Date;
title: string;
color?: string;
allDay?: boolean;
};
type EC = {
prev(): EC;
next(): EC;
setOption(name: string, value: unknown): EC;
getOption(name: string): unknown;
getView(): { type: string; title: string };
};
let loading = $state(false);
let currentView = $state("dayGridMonth");
let titleText = $state("");
let cal = $state<EC | null>(null);
let calBody = $state<HTMLElement | null>(null);
// Move the all-day strip from inside the sticky header to the bottom of the
// scrollable ec-main so it appears below the time slots.
function repositionAllDay() {
const main = calBody?.querySelector<HTMLElement>('.ec-time-grid .ec-main');
if (!main) return; // not a time-grid view
const allDay = main.querySelector<HTMLElement>(':scope > .ec-header > .ec-all-day');
if (!allDay) return;
main.appendChild(allDay);
}
const views = [
{ id: "dayGridMonth", label: "Month" },
{ id: "timeGridWeek", label: "Week" },
{ id: "timeGridDay", label: "Day" },
];
function goToToday() {
cal?.setOption("date", new Date());
}
function goPrev() {
cal?.prev();
}
function goNext() {
cal?.next();
}
function switchView(viewId: string) {
cal?.setOption("view", viewId);
}
async function loadEvents(start: Date, end: Date): Promise<void> {
loading = true;
try {
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
});
const res = await fetch(`/calendar/events?${params}`);
if (!res.ok) return;
const body = await res.json();
const entries: Array<Record<string, unknown>> = body?.data ?? [];
options.events = entries
.filter((e) => e.startDate && e.endDate)
.map((e) => ({
id: e.id as string,
title: (e.name as string) || "(No title)",
start: new Date(e.startDate as string),
end: new Date(e.endDate as string),
allDay: (e.allDayFlag as boolean) ?? false,
color:
((e.type as Record<string, unknown> | null)?.displayColor as string | undefined) ||
((e.status as Record<string, unknown> | null)?.color as string | undefined) ||
undefined,
})) satisfies CalendarEvent[];
} finally {
loading = false;
}
}
let options = $state({
view: "dayGridMonth",
headerToolbar: false,
editable: false,
selectable: true,
nowIndicator: true,
dayMaxEvents: true,
slotEventOverlap: false,
eventTimeFormat: () => '',
height: "100%",
events: [] as CalendarEvent[],
datesSet: (info: { start: Date; end: Date; view: { type: string; title: string } }) => {
titleText = info.view.title;
currentView = info.view.type;
loadEvents(info.start, info.end);
},
viewDidMount: () => {
// viewDidMount fires each time the view component is mounted (on view-type
// switches). Use setTimeout to let the DOM fully paint first.
setTimeout(repositionAllDay, 0);
},
});
</script>
<svelte:head>
<title>Calendar — Project Optima</title>
</svelte:head>
<div class="calendar-page">
<div class="calendar-pane">
<div class="calendar-header">
<div class="calendar-header-left">
<svg
class="calendar-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<h2 class="calendar-title">Calendar</h2>
</div>
<div class="calendar-header-controls">
<div class="calendar-nav">
<button class="calendar-nav-btn" onclick={goPrev} aria-label="Previous">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button class="calendar-nav-btn calendar-today-btn" onclick={goToToday}>Today</button>
<button class="calendar-nav-btn" onclick={goNext} aria-label="Next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<span class="calendar-range-title">{titleText}</span>
<div class="calendar-view-switcher">
{#each views as v}
<button
class="calendar-view-btn"
class:active={currentView === v.id}
onclick={() => switchView(v.id)}
>{v.label}</button>
{/each}
</div>
</div>
</div>
<div class="calendar-body" bind:this={calBody}>
<Calendar bind:this={cal} plugins={[DayGrid, TimeGrid, Interaction]} {options} />
</div>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /calendar/events?start=<ISO>&end=<ISO> — fetch schedule entries for the authenticated user */
export const GET: RequestHandler = async ({ url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const startStr = url.searchParams.get("start");
const endStr = url.searchParams.get("end");
if (!startStr || !endStr) throw error(400, "start and end query params are required");
const start = new Date(startStr);
const end = new Date(endStr);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw error(400, "Invalid date format for start or end");
}
try {
const entries = await optima.schedule.fetchMe(accessToken, start, end);
return json({ data: entries });
} catch (err: unknown) {
console.error("Failed to fetch schedule entries:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to fetch schedule entries");
}
};
+2 -1
View File
@@ -264,7 +264,8 @@
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
</svg>
<span class="mono"
>{company.identifier || company.id?.slice(0, 8)}</span
>{company.identifier ||
company.id?.toString().slice(0, 8)}</span
>
</div>
{/if}
+4 -1
View File
@@ -127,7 +127,10 @@ export function configStatusClass(statusName?: string): string {
export function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
const dateOnly = dateStr.split("T")[0];
const date = new Date(dateOnly + "T00:00:00");
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
+24 -1
View File
@@ -3,6 +3,25 @@ import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
function withTimeoutFallback<T>(
promise: Promise<T>,
timeoutMs: number,
fallback: T,
): Promise<T> {
return new Promise<T>((resolve) => {
const timer = setTimeout(() => resolve(fallback), timeoutMs);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch(() => {
clearTimeout(timer);
resolve(fallback);
});
});
}
export const load: PageServerLoad = async ({ locals, url }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
@@ -68,7 +87,11 @@ export const load: PageServerLoad = async ({ locals, url }) => {
optima.sales
.fetchOpportunityTypes(accessToken)
.catch(() => ({ data: [] })),
optima.sales.fetchMetrics(accessToken).catch(() => null),
withTimeoutFallback(
optima.sales.fetchMetrics(accessToken).catch(() => null),
2500,
null,
),
]);
const opportunities =
+176 -55
View File
@@ -7,12 +7,21 @@
OpportunityType,
} from "$lib/optima-api/modules/sales";
import { formatDate } from "$lib/utils";
import { statusColorClass, statusLabel, isEquivalencyStatus, originalStatusName } from "$lib/sales-utils";
import {
statusColorClass,
statusLabel,
isEquivalencyStatus,
originalStatusName,
} from "$lib/sales-utils";
import CreateOpportunityModal from "../../components/CreateOpportunityModal.svelte";
import AccessDenied from "../../components/AccessDenied.svelte";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import Pagination from "../../components/Pagination.svelte";
import OpportunityContextMenu from "../../components/OpportunityContextMenu.svelte";
import "../../styles/sales/sales.css";
let contextMenu: OpportunityContextMenu;
type SalesOpportunity = {
id: string;
cwOpportunityId?: number;
@@ -21,7 +30,7 @@
stage?: { id?: number; name?: string } | null;
status?: { id?: number; name?: string } | null;
priority?: { id?: number; name?: string } | null;
rating?: { id?: number; name?: string } | null;
interest?: "HOT" | "WARM" | "COLD" | null;
primarySalesRep?: {
id?: number;
identifier?: string;
@@ -32,7 +41,7 @@
identifier?: string;
name?: string;
} | null;
company?: { id?: number | string; name?: string } | null;
company?: { id?: string; name?: string } | null;
expectedCloseDate?: string | null;
closedDate?: string | null;
closedFlag?: boolean;
@@ -62,7 +71,11 @@
function fmt$(v: number | null | undefined): string {
if (v == null) return "—";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(v);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(v);
}
function fmtPct(v: number | null | undefined): string {
if (v == null) return "—";
@@ -102,7 +115,11 @@
const params = new URLSearchParams();
params.set("tab", "dashboard");
if (!hideClosed) params.set("dashIncludeClosed", "true");
goto(`/sales?${params.toString()}`, { replaceState: false, keepFocus: true, noScroll: true });
goto(`/sales?${params.toString()}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
$: filteredMyOpportunities = dashSearch.trim()
? opportunities.filter((op) => {
@@ -157,7 +174,11 @@
function handleDashFilterClickOutside(e: MouseEvent) {
if (!dashFilterOpen) return;
const target = e.target as Node;
if (dashFilterBtnEl?.contains(target) || dashFilterPopoverEl?.contains(target)) return;
if (
dashFilterBtnEl?.contains(target) ||
dashFilterPopoverEl?.contains(target)
)
return;
dashFilterOpen = false;
}
@@ -222,7 +243,7 @@
$: opportunities = data.opportunities;
function navigateWithFilters(
opts: { page?: number; keepFocus?: boolean } = {},
opts: { page?: number; keepFocus?: boolean } = {}
) {
const params = new URLSearchParams();
params.set("page", String(opts.page ?? currentPage));
@@ -274,8 +295,8 @@
function handleRowKeydown(
e: KeyboardEvent,
oppId: number,
from: "dashboard" | "opportunities",
oppId: string | number,
from: "dashboard" | "opportunities"
) {
const row = e.currentTarget as HTMLElement;
if (e.key === "Enter" || e.key === " ") {
@@ -305,7 +326,13 @@
}
function ownerLabel(op: SalesOpportunity): string {
return op.primarySalesRep?.name || op.secondarySalesRep?.name || "—";
return (
op.primarySalesRep?.name ||
op.primarySalesRep?.identifier ||
op.secondarySalesRep?.name ||
op.secondarySalesRep?.identifier ||
"—"
);
}
function companyLabel(op: SalesOpportunity): string {
@@ -352,7 +379,12 @@
}
</script>
<svelte:window on:click={(e) => { handleFilterClickOutside(e); handleDashFilterClickOutside(e); }} />
<svelte:window
on:click={(e) => {
handleFilterClickOutside(e);
handleDashFilterClickOutside(e);
}}
/>
<svelte:head>
<title>Sales — Project Optima</title>
@@ -443,7 +475,14 @@
on:click={() => (showCreateModal = true)}
aria-label="Create opportunity"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -464,7 +503,9 @@
{currentUser?.name ? currentUser.name.split(" ")[0] : "Welcome"}
</h2>
<p class="dash-subtitle">Your sales at a glance</p>
<p class="dash-metrics-note">Overview data refreshes every 5 min</p>
<p class="dash-metrics-note">
Overview data refreshes every 5 min
</p>
</div>
<!-- Activity -->
@@ -474,17 +515,23 @@
<!-- Open / Assigned summary row -->
<div class="dash-activity-row">
<div class="dash-activity-item">
<span class="dash-activity-number">{m?.openOpportunityCount ?? myOpenOpps.length}</span>
<span class="dash-activity-number"
>{m?.openOpportunityCount ?? myOpenOpps.length}</span
>
<span class="dash-activity-label">Open</span>
</div>
<div class="dash-activity-divider"></div>
<div class="dash-activity-item">
<span class="dash-activity-number">{m?.assignedOpportunityCount ?? opportunities.length}</span>
<span class="dash-activity-number"
>{m?.assignedOpportunityCount ?? opportunities.length}</span
>
<span class="dash-activity-label">Assigned</span>
</div>
<div class="dash-activity-divider"></div>
<div class="dash-activity-item">
<span class="dash-activity-number">{(m?.closedOpportunityCount?.mtd ?? 0)}</span>
<span class="dash-activity-number"
>{m?.closedOpportunityCount?.mtd ?? 0}</span
>
<span class="dash-activity-label">Closed MTD</span>
</div>
</div>
@@ -509,11 +556,18 @@
</div>
<div class="dash-wl-bar">
<div class="dash-wl-bar-won" style="width: {winPct}%"></div>
<div class="dash-wl-bar-lost" style="width: {100 - winPct}%"></div>
<div
class="dash-wl-bar-lost"
style="width: {100 - winPct}%"
></div>
</div>
<div class="dash-wl-footer">
<span class="dash-wl-rate">{(winPct).toFixed(0)}% win rate</span>
<span class="dash-wl-ytd">{m?.winCount?.ytd ?? 0}W / {m?.lossCount?.ytd ?? 0}L YTD</span>
<span class="dash-wl-rate"
>{winPct.toFixed(0)}% win rate</span
>
<span class="dash-wl-ytd"
>{m?.winCount?.ytd ?? 0}W / {m?.lossCount?.ytd ?? 0}L YTD</span
>
</div>
</div>
{:else}
@@ -529,15 +583,25 @@
<div class="dash-fin-block">
<div class="dash-fin-kv">
<span class="dash-fin-kv-label">Pipeline</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}>{fmt$(m?.pipelineRevenue)}</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}
>{fmt$(m?.pipelineRevenue)}</span
>
</div>
{#if m}
{@const wPct = m.pipelineRevenue > 0 ? (m.weightedPipelineRevenue / m.pipelineRevenue) * 100 : 0}
{@const wPct =
m.pipelineRevenue > 0
? (m.weightedPipelineRevenue / m.pipelineRevenue) * 100
: 0}
<div class="dash-fin-bar-row">
<div class="dash-fin-bar">
<div class="dash-fin-bar-fill" style="width: {Math.min(wPct, 100)}%"></div>
<div
class="dash-fin-bar-fill"
style="width: {Math.min(wPct, 100)}%"
></div>
</div>
<span class="dash-fin-bar-label">{fmt$(m.weightedPipelineRevenue)} weighted</span>
<span class="dash-fin-bar-label"
>{fmt$(m.weightedPipelineRevenue)} weighted</span
>
</div>
{/if}
</div>
@@ -546,14 +610,17 @@
<div class="dash-fin-block">
<div class="dash-fin-kv">
<span class="dash-fin-kv-label">Closed MTD</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}>{fmt$(m?.closedWonRevenueMtd)}</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}
>{fmt$(m?.closedWonRevenueMtd)}</span
>
</div>
<div class="dash-fin-kv dash-fin-kv--sub">
<span class="dash-fin-kv-label">Closed YTD</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}>{fmt$(m?.closedWonRevenueYtd)}</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}
>{fmt$(m?.closedWonRevenueYtd)}</span
>
</div>
</div>
</div>
<!-- Performance -->
@@ -563,17 +630,25 @@
<!-- Days to close + avg deal size inline -->
<div class="dash-activity-row">
<div class="dash-activity-item">
<span class="dash-activity-number" class:dash-ph={!m}>{fmtDays(m?.avgDaysToClose)}</span>
<span class="dash-activity-number" class:dash-ph={!m}
>{fmtDays(m?.avgDaysToClose)}</span
>
<span class="dash-activity-label">Avg Close</span>
</div>
<div class="dash-activity-divider"></div>
<div class="dash-activity-item">
<span class="dash-activity-number dash-stat-number--money" class:dash-ph={!m}>{fmt$(m?.avgOpenDealSize)}</span>
<span
class="dash-activity-number dash-stat-number--money"
class:dash-ph={!m}>{fmt$(m?.avgOpenDealSize)}</span
>
<span class="dash-activity-label">Avg Open</span>
</div>
<div class="dash-activity-divider"></div>
<div class="dash-activity-item">
<span class="dash-activity-number dash-stat-number--money" class:dash-ph={!m}>{fmt$(m?.avgWonDealSize?.ytd)}</span>
<span
class="dash-activity-number dash-stat-number--money"
class:dash-ph={!m}>{fmt$(m?.avgWonDealSize?.ytd)}</span
>
<span class="dash-activity-label">Avg Won</span>
</div>
</div>
@@ -582,23 +657,39 @@
<div class="dash-fin-block">
<div class="dash-fin-kv">
<span class="dash-fin-kv-label">Win Rate MTD</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}>{fmtPct(m?.winRate?.mtd)}</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}
>{fmtPct(m?.winRate?.mtd)}</span
>
</div>
{#if m}
<div class="dash-fin-bar-row">
<div class="dash-fin-bar dash-fin-bar--green">
<div class="dash-fin-bar-fill" style="width: {Math.min((m.winRate.mtd ?? 0) * 100, 100)}%"></div>
<div
class="dash-fin-bar-fill"
style="width: {Math.min(
(m.winRate.mtd ?? 0) * 100,
100
)}%"
></div>
</div>
</div>
{/if}
<div class="dash-fin-kv dash-fin-kv--sub">
<span class="dash-fin-kv-label">Win Rate YTD</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}>{fmtPct(m?.winRate?.ytd)}</span>
<span class="dash-fin-kv-value" class:dash-ph={!m}
>{fmtPct(m?.winRate?.ytd)}</span
>
</div>
{#if m}
<div class="dash-fin-bar-row">
<div class="dash-fin-bar dash-fin-bar--green">
<div class="dash-fin-bar-fill" style="width: {Math.min((m.winRate.ytd ?? 0) * 100, 100)}%"></div>
<div
class="dash-fin-bar-fill"
style="width: {Math.min(
(m.winRate.ytd ?? 0) * 100,
100
)}%"
></div>
</div>
</div>
{/if}
@@ -643,8 +734,17 @@
aria-expanded={dashFilterOpen}
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polygon
points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"
/>
</svg>
Filters
{#if dashHideClosed}
@@ -652,16 +752,23 @@
{/if}
</button>
{#if dashFilterOpen}
<div class="sales-filter-popover" bind:this={dashFilterPopoverEl}>
<div
class="sales-filter-popover"
bind:this={dashFilterPopoverEl}
>
<label class="sales-filter-option">
<input
type="checkbox"
checked={!dashHideClosed}
on:change={() => {
on:change={() => {
const newVal = !dashHideClosed;
dashHideClosed = newVal;
dashFilterOpen = false;
console.log("[dash filter] hideClosed:", newVal, "navigating...");
console.log(
"[dash filter] hideClosed:",
newVal,
"navigating..."
);
navigateDashFilters(newVal);
}}
/>
@@ -702,7 +809,7 @@
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-rating">Interest</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
</tr>
@@ -715,6 +822,7 @@
tabindex="0"
on:click={() =>
goto(`/sales/opportunity/${opp.id}?from=dashboard`)}
on:contextmenu={(e) => contextMenu.open(e, opp)}
on:keydown={(e) =>
handleRowKeydown(e, opp.id, "dashboard")}
style="cursor: pointer;"
@@ -739,34 +847,35 @@
</span>
</td>
<td class="col-rating">
{#if opp.rating?.name}
{#if opp.interest}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
opp.interest
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span
style={getDotStyle(
level,
opp.rating.name,
)}
style={getDotStyle(level, opp.interest)}
></span>
{/each}
</span>
{opp.rating.name}
{opp.interest}
</span>
{:else}
{/if}
</td>
<td class="col-close" class:date-overdue={!opp.closedFlag && opp.expectedCloseDate && new Date(opp.expectedCloseDate) < new Date()}
<td
class="col-close"
class:date-overdue={!opp.closedFlag &&
opp.expectedCloseDate &&
new Date(opp.expectedCloseDate) < new Date()}
>{formatDate(opp.expectedCloseDate)}</td
>
<td class="col-updated"
>{formatDate(
opp.cwLastUpdated || opp.updatedAt,
opp.cwLastUpdated || opp.updatedAt
)}</td
>
</tr>
@@ -894,7 +1003,7 @@
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-rating">Interest</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
@@ -908,6 +1017,7 @@
tabindex="0"
on:click={() =>
goto(`/sales/opportunity/${opp.id}?from=opportunities`)}
on:contextmenu={(e) => contextMenu.open(e, opp)}
on:keydown={(e) =>
handleRowKeydown(e, opp.id, "opportunities")}
style="cursor: pointer;"
@@ -935,27 +1045,31 @@
</span>
</td>
<td class="col-rating">
{#if opp.rating?.name}
{#if opp.interest}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
opp.interest
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span
style={getDotStyle(level, opp.rating.name)}
<span style={getDotStyle(level, opp.interest)}
></span>
{/each}
</span>
{opp.rating.name}
{opp.interest}
</span>
{:else}
{/if}
</td>
<td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close" class:date-overdue={!opp.closedFlag && opp.expectedCloseDate && new Date(opp.expectedCloseDate) < new Date()}>
<td
class="col-close"
class:date-overdue={!opp.closedFlag &&
opp.expectedCloseDate &&
new Date(opp.expectedCloseDate) < new Date()}
>
{formatDate(opp.expectedCloseDate)}
</td>
<td class="col-updated">
@@ -989,6 +1103,13 @@
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
opportunityTypes={data.opportunityTypes ?? []}
/>
<OpportunityContextMenu
bind:this={contextMenu}
permissions={data.permissions}
on:workflowChanged={() => invalidateAll()}
/>
<style>
+28 -37
View File
@@ -2,7 +2,10 @@
import { goto, afterNavigate } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import type { OpportunityType } from "$lib/optima-api/modules/sales";
import type {
OpportunityType,
SalesOpportunity,
} from "$lib/optima-api/modules/sales";
import { formatDate } from "$lib/utils";
import {
statusColorClass,
@@ -13,35 +16,10 @@
import CreateOpportunityModal from "../../../components/CreateOpportunityModal.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte";
import Pagination from "../../../components/Pagination.svelte";
import OpportunityContextMenu from "../../../components/OpportunityContextMenu.svelte";
import "../../../styles/sales/sales.css";
type SalesOpportunity = {
id: string;
cwOpportunityId?: number;
name: string;
type?: { id?: number; name?: string } | null;
stage?: { id?: number; name?: string } | null;
status?: { id?: number; name?: string } | null;
priority?: { id?: number; name?: string } | null;
rating?: { id?: number; name?: string } | null;
primarySalesRep?: {
id?: number;
identifier?: string;
name?: string;
} | null;
secondarySalesRep?: {
id?: number;
identifier?: string;
name?: string;
} | null;
company?: { id?: number | string; name?: string } | null;
expectedCloseDate?: string | null;
closedDate?: string | null;
closedFlag?: boolean;
cwLastUpdated?: string | null;
createdAt?: string;
updatedAt?: string;
};
let contextMenu: OpportunityContextMenu;
export let data: {
permissions: PermissionMap;
@@ -63,7 +41,7 @@
// directMap: type id → OpportunityType (exact match)
// equivMap: type id → OpportunityType (matched via optimaEquivalency)
$: directMap = new Map<number, OpportunityType>(
data.opportunityTypes.map((t) => [t.id, t]),
data.opportunityTypes.map((t) => [t.id, t])
);
$: equivMap = (() => {
const m = new Map<number, OpportunityType>();
@@ -152,7 +130,7 @@
$: opportunities = data.opportunities;
function navigateWithFilters(
opts: { page?: number; keepFocus?: boolean } = {},
opts: { page?: number; keepFocus?: boolean } = {}
) {
const params = new URLSearchParams();
params.set("page", String(opts.page ?? currentPage));
@@ -214,9 +192,14 @@
return op.status?.name || "Open";
}
function ownerLabel(op: SalesOpportunity): string {
return op.primarySalesRep?.name || op.secondarySalesRep?.name || "—";
return (
op.primarySalesRep?.name ||
op.primarySalesRep?.identifier ||
op.secondarySalesRep?.name ||
op.secondarySalesRep?.identifier ||
"—"
);
}
function companyLabel(op: SalesOpportunity): string {
@@ -407,7 +390,7 @@
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-status">Status</th>
<th class="col-rating">Rating</th>
<th class="col-rating">Interest</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
@@ -419,6 +402,7 @@
class="sales-row"
class:closed-row={opp.closedFlag}
on:click={() => goto(`/sales/opportunity/${opp.id}`)}
on:contextmenu={(e) => contextMenu.open(e, opp)}
style="cursor: pointer;"
>
<td class="col-opportunity">
@@ -444,19 +428,19 @@
</span>
</td>
<td class="col-rating">
{#if opp.rating?.name}
{#if opp.interest}
<span
class="sales-rating-badge {ratingHeatClass(
opp.rating.name,
opp.interest
)}"
>
<span class="sales-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opp.rating.name)}
<span style={getDotStyle(level, opp.interest)}
></span>
{/each}
</span>
{opp.rating.name}
{opp.interest}
</span>
{:else}
@@ -492,6 +476,13 @@
<CreateOpportunityModal
bind:isOpen={showCreateModal}
onSuccess={() => invalidateAll()}
opportunityTypes={data.opportunityTypes ?? []}
/>
<OpportunityContextMenu
bind:this={contextMenu}
permissions={data.permissions}
on:workflowChanged={() => invalidateAll()}
/>
<style>
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}
try {
const [result, permissions, workflowResult] = await Promise.all([
const [result, permissions, workflowResult, opportunityTypesResult] = await Promise.all([
optima.sales.fetchOne(accessToken, params.id, [
"notes",
"contacts",
@@ -58,6 +58,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
console.error("[Workflow] Failed to load workflow status:", err);
return null;
}),
optima.sales.fetchOpportunityTypes(accessToken).catch(() => ({ data: [] })),
]);
const { writeFileSync } = await import("fs");
@@ -84,6 +85,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
accessToken,
permissions,
workflowStatus,
opportunityTypes: opportunityTypesResult?.data ?? [],
};
} catch (err) {
handleApiError(err);
@@ -3,6 +3,12 @@
import { onMount } from "svelte";
import { invalidateAll } from "$app/navigation";
import type { PageData } from "./types";
import {
STATUS_ID_TO_KEY,
TERMINAL_STATUSES,
type WorkflowResult,
type WorkflowStatusResponse,
} from "$lib/optima-api/modules/sales";
// Tab components
import OpportunitySidebar from "./components/OpportunitySidebar.svelte";
@@ -12,6 +18,7 @@
import ProductsTab from "./components/ProductsTab.svelte";
import QuotesTab from "./components/QuotesTab.svelte";
import OpportunityReportsTab from "./components/OpportunityReportsTab.svelte";
import DiscountsTab from "./components/DiscountsTab.svelte";
import WorkflowPanel from "./components/WorkflowPanel.svelte";
import UnsavedChangesDialog from "../../../../components/UnsavedChangesDialog.svelte";
@@ -24,10 +31,26 @@
$: products = data.products;
$: quotes = data.quotes ?? [];
$: permissions = data.permissions;
$: workflowStatus = data.workflowStatus ?? null;
// Closed opportunity lockdown no edits except admin delete
// workflowStatus can be locally overridden after a workflow action for instant UI update.
// Uses the same override pattern as localActivities so that Svelte 5's $: pre-effects
// don't clobber the manual assignment during the same update flush.
let workflowStatusOverride: WorkflowStatusResponse | null = null;
$: if (data.workflowStatus !== undefined) workflowStatusOverride = null; // reset on server data refresh
$: workflowStatus = workflowStatusOverride ?? data.workflowStatus ?? null;
// activities can be speculatively updated with newly created activities from workflow actions
let localActivities: typeof opportunity.activities | null = null;
$: if (data.opportunity) localActivities = null; // reset when server data refreshes
$: effectiveActivities = localActivities ?? opportunity?.activities ?? [];
// Closed opportunity lockdown no edits except admin delete.
// Also immediately reflects Won/Lost/Canceled workflow status without needing a reload.
$: isClosedOpportunity = (() => {
const wfKey = workflowStatus
? (STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null)
: null;
if (wfKey && TERMINAL_STATUSES.has(wfKey)) return true;
if (!opportunity) return false;
const statusText =
`${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
@@ -39,6 +62,35 @@
);
})();
/** Handle a completed workflow action: update UI immediately without a full page reload. */
function handleWorkflowChanged(
e: CustomEvent<{ result: WorkflowResult; freshWorkflowStatus: WorkflowStatusResponse | null }>,
) {
const { result, freshWorkflowStatus } = e.detail;
// Update workflow status immediately so badges and action buttons reflect the new state
if (freshWorkflowStatus) {
workflowStatusOverride = freshWorkflowStatus;
} else if (result.newStatusId !== null) {
// Optimistic update using only the data in the action response
workflowStatusOverride = {
...(workflowStatus ?? ({} as WorkflowStatusResponse)),
currentStatusId: result.newStatusId,
currentStatus: result.newStatus ?? "",
coldCheck: result.coldCheck,
availableActions: workflowStatus?.availableActions ?? [],
};
}
// Prepend newly created activities so the Activity tab updates immediately
if (result.activitiesCreated?.length) {
localActivities = [
...result.activitiesCreated,
...(localActivities ?? opportunity?.activities ?? []),
];
}
}
let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null;
@@ -67,6 +119,7 @@
const tabs = [
"Overview",
"Products",
"Discounts",
"Quotes",
"Notes",
"Activity",
@@ -178,6 +231,7 @@
{permissions}
{isClosedOpportunity}
{workflowStatus}
opportunityTypes={data.opportunityTypes ?? []}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
@@ -339,9 +393,9 @@
{permissions}
{opportunityId}
{quotes}
activities={opportunity?.activities ?? []}
activities={effectiveActivities}
inline={true}
on:workflowChanged={() => invalidateAll()}
on:workflowChanged={handleWorkflowChanged}
/>
{/if}
</div>
@@ -349,12 +403,14 @@
{#if activeTab === "Overview"}
<OverviewTab
{opportunity}
{opportunityId}
{notes}
{contacts}
{products}
{permissions}
on:selectProduct={handleSelectProduct}
on:switchTab={(e) => guardedSetTab(e.detail as Tab)}
on:viewQuote={handleViewQuote}
/>
{:else if activeTab === "Products"}
<ProductsTab
@@ -366,10 +422,13 @@
{permissions}
{isClosedOpportunity}
salesTaxRate={opportunity?.expectedSalesTaxRate ?? null}
taxCodeDescription={opportunity?.taxCodeDescription ?? null}
bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
/>
{:else if activeTab === "Discounts"}
<DiscountsTab {products} />
{:else if activeTab === "Quotes"}
<QuotesTab
accessToken={data.accessToken}
@@ -393,7 +452,7 @@
{:else if activeTab === "Activity"}
<ActivityTab
{opportunityId}
activities={opportunity?.activities ?? []}
activities={effectiveActivities}
on:viewQuote={handleViewQuote}
/>
{:else if activeTab === "Reports"}
@@ -0,0 +1,247 @@
<script lang="ts">
import type { OpportunityProduct } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let products: OpportunityProduct[] = [];
$: discountedProducts = products.filter(
(p) => (p.discount ?? 0) !== 0 && !p.cancelled
);
$: totalDiscount = discountedProducts.reduce(
(sum, p) => sum + (p.discount ?? 0) * (p.quantity ?? 1),
0
);
$: totalListPrice = discountedProducts.reduce(
(sum, p) => sum + (p.listPrice ?? 0) * (p.quantity ?? 1),
0
);
$: totalUnitPrice = discountedProducts.reduce(
(sum, p) => sum + (p.unitPrice ?? 0) * (p.quantity ?? 1),
0
);
function fmtMoney(value: number): string {
return value.toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
}
function fmtPercent(list: number, discount: number): string {
if (list === 0) return "—";
const pct = (discount / list) * 100;
return `${pct.toFixed(1)}%`;
}
</script>
<div class="discounts-tab">
<!-- Summary cards -->
{#if discountedProducts.length > 0}
<div class="discounts-summary">
<div class="discount-summary-card">
<span class="discount-summary-label">Total List Price</span>
<span class="discount-summary-value">{fmtMoney(totalListPrice)}</span>
</div>
<div class="discount-summary-card">
<span class="discount-summary-label">Total Discounts</span>
<span class="discount-summary-value discount-negative"
>{fmtMoney(totalDiscount)}</span
>
</div>
<div class="discount-summary-card">
<span class="discount-summary-label">Net Price</span>
<span class="discount-summary-value">{fmtMoney(totalUnitPrice)}</span>
</div>
</div>
{/if}
<!-- Discounts table -->
{#if discountedProducts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No discounts applied" />
</div>
{:else}
<div class="discounts-table-wrap">
<table class="discounts-table">
<thead>
<tr>
<th class="col-product">Product</th>
<th class="col-num">Qty</th>
<th class="col-num">List Price</th>
<th class="col-num">Discount</th>
<th class="col-num">% Off</th>
<th class="col-num">Unit Price</th>
<th class="col-num">Ext. Discount</th>
</tr>
</thead>
<tbody>
{#each discountedProducts as product (product.id)}
<tr>
<td class="col-product">
<div class="discount-product-name">
{product.forecastDescription || product.productDescription || "—"}
</div>
{#if product.catalogItem?.identifier}
<div class="discount-product-sku">
{product.catalogItem.identifier}
</div>
{/if}
</td>
<td class="col-num">{product.quantity ?? 0}</td>
<td class="col-num">{fmtMoney(product.listPrice ?? 0)}</td>
<td class="col-num discount-negative">
{fmtMoney(product.discount ?? 0)}
</td>
<td class="col-num">
{fmtPercent(product.listPrice ?? 0, product.discount ?? 0)}
</td>
<td class="col-num">{fmtMoney(product.unitPrice ?? 0)}</td>
<td class="col-num discount-negative">
{fmtMoney((product.discount ?? 0) * (product.quantity ?? 1))}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td class="col-product"><strong>Total</strong></td>
<td class="col-num"></td>
<td class="col-num"><strong>{fmtMoney(totalListPrice)}</strong></td>
<td class="col-num discount-negative">
<strong>{fmtMoney(totalDiscount)}</strong>
</td>
<td class="col-num">
{fmtPercent(totalListPrice, totalDiscount)}
</td>
<td class="col-num"><strong>{fmtMoney(totalUnitPrice)}</strong></td>
<td class="col-num discount-negative">
<strong>{fmtMoney(totalDiscount)}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
{/if}
</div>
<style>
.discounts-tab {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
}
.discounts-summary {
display: flex;
gap: 12px;
padding: 0 16px;
}
.discount-summary-card {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border-radius: 8px;
background: var(--bg-raised, #f7f8fa);
border: 1px solid var(--border-subtle, #e2e5ea);
}
.discount-summary-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.discount-summary-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a2e);
font-variant-numeric: tabular-nums;
}
.discount-negative {
color: var(--color-danger, #dc2626);
}
.discounts-table-wrap {
overflow-x: auto;
padding: 0 16px;
}
.discounts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.discounts-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #6b7280);
border-bottom: 2px solid var(--border-subtle, #e2e5ea);
white-space: nowrap;
}
.discounts-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle, #e2e5ea);
vertical-align: middle;
}
.discounts-table tbody tr:last-child td {
border-bottom: none;
}
.discounts-table tbody tr:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.02));
}
.discounts-table tfoot td {
padding: 10px 12px;
border-top: 2px solid var(--border-subtle, #e2e5ea);
font-weight: 600;
}
.col-product {
min-width: 200px;
}
.col-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.discounts-table th.col-num {
text-align: right;
}
.discount-product-name {
font-weight: 500;
color: var(--text-primary, #1a1a2e);
}
.discount-product-sku {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
margin-top: 2px;
}
@media (max-width: 768px) {
.discounts-summary {
flex-direction: column;
}
}
</style>
@@ -6,6 +6,7 @@
import type {
SalesOpportunity,
WorkflowStatusResponse,
OpportunityType,
} from "$lib/optima-api/modules/sales";
import {
WORKFLOW_STATUS_LABELS,
@@ -30,16 +31,17 @@
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
const raw = opportunity.expectedCloseDate;
const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
const dateOnly = raw.split("T")[0];
const close = new Date(dateOnly + "T00:00:00");
const now = new Date();
const closeDay = new Date(
close.getFullYear(),
close.getMonth(),
close.getDate(),
close.getDate()
);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round(
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
(closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
})();
@@ -50,6 +52,7 @@
export let accessToken: string | null = null;
export let isClosedOpportunity: boolean = false;
export let workflowStatus: WorkflowStatusResponse | null = null;
export let opportunityTypes: OpportunityType[] = [];
// Workflow-derived status for badge
$: wfStatusKey = workflowStatus
@@ -206,6 +209,17 @@
let editSites: EditSite[] = [];
let sitesLoading = false;
// Type dropdown state
let editTypeId: number | null = null;
let editTypeName = "";
let typeDropdownOpen = false;
let typeSearchQuery = "";
$: filteredTypes = typeSearchQuery
? opportunityTypes.filter((t) =>
t.name.toLowerCase().includes(typeSearchQuery.toLowerCase())
)
: opportunityTypes;
// Display-mode contact details (fetched from company details API)
let displayContacts: EditContact[] = [];
let displayContactsCompanyId: string | null = null;
@@ -223,6 +237,7 @@
let secRepTriggerEl: HTMLElement | null = null;
let contactTriggerEl: HTMLElement | null = null;
let siteTriggerEl: HTMLElement | null = null;
let typeTriggerEl: HTMLElement | null = null;
let dropdownPos = { top: 0, left: 0, width: 280 };
function positionDropdown(triggerEl: HTMLElement | null) {
@@ -262,7 +277,16 @@
let companySearchTimer: ReturnType<typeof setTimeout> | null = null;
async function loadCWMembers() {
if ($cwMembers.length > 0) return;
const hasInvalidIds = $cwMembers.some((m) => {
const id = Number((m as { id: unknown }).id);
return !Number.isFinite(id);
});
if (hasInvalidIds) {
cwMembers.reset();
}
if ($cwMembers.length > 0 && !hasInvalidIds) return;
membersLoading = true;
try {
await cwMembers.load();
@@ -284,7 +308,7 @@
companySearchTimer = setTimeout(async () => {
try {
const json = await clientFetch<{ data: CompanySearchResult[] }>(
`/api/companies/search?search=${encodeURIComponent(query)}&rpp=15`,
`/api/companies/search?search=${encodeURIComponent(query)}&rpp=15`
);
companyResults = json.data ?? [];
} catch (err) {
@@ -297,7 +321,8 @@
}
function selectRep(member: CWMember) {
editRepId = member.id;
const parsed = Number(member.id);
editRepId = Number.isFinite(parsed) ? parsed : null;
editRepName = member.name;
repDropdownOpen = false;
repSearchQuery = "";
@@ -305,7 +330,8 @@
}
function selectSecRep(member: CWMember) {
editSecRepId = member.id;
const parsed = Number(member.id);
editSecRepId = Number.isFinite(parsed) ? parsed : null;
editSecRepName = member.name;
secRepDropdownOpen = false;
secRepSearchQuery = "";
@@ -352,6 +378,14 @@
isDirty = true;
}
function selectType(t: OpportunityType) {
editTypeId = t.id;
editTypeName = t.name;
typeDropdownOpen = false;
typeSearchQuery = "";
isDirty = true;
}
type CompanySite = EditSite;
async function loadEditSites() {
const companyOptimaId = opportunity?.company?.id;
@@ -359,7 +393,7 @@
sitesLoading = true;
try {
const json = await clientFetch<{ data: CompanySite[] }>(
`/api/companies/${companyOptimaId}/sites`,
`/api/companies/${companyOptimaId}/sites`
);
editSites = json.data ?? [];
} catch (err) {
@@ -418,7 +452,7 @@
} catch (err) {
console.error(
"[OpportunitySidebar] Failed to load display contacts:",
err,
err
);
displayContacts = [];
displayContactsCompanyId = companyOptimaId;
@@ -473,6 +507,7 @@
secRepDropdownOpen = false;
contactDropdownOpen = false;
siteDropdownOpen = false;
typeDropdownOpen = false;
}
}
@@ -484,13 +519,15 @@
companyDropdownOpen ||
secRepDropdownOpen ||
contactDropdownOpen ||
siteDropdownOpen)
siteDropdownOpen ||
typeDropdownOpen)
) {
repDropdownOpen = false;
companyDropdownOpen = false;
secRepDropdownOpen = false;
contactDropdownOpen = false;
siteDropdownOpen = false;
typeDropdownOpen = false;
e.stopPropagation();
}
}
@@ -519,13 +556,19 @@
}
// Initialize rep selection
editRepId = opportunity.primarySalesRep?.id ?? null;
{
const parsed = Number(opportunity.primarySalesRep?.id);
editRepId = Number.isFinite(parsed) ? parsed : null;
}
editRepName = opportunity.primarySalesRep?.name ?? "";
repSearchQuery = "";
repDropdownOpen = false;
// Initialize secondary rep selection
editSecRepId = opportunity.secondarySalesRep?.id ?? null;
{
const parsed = Number(opportunity.secondarySalesRep?.id);
editSecRepId = Number.isFinite(parsed) ? parsed : null;
}
editSecRepName = opportunity.secondarySalesRep?.name ?? "";
secRepSearchQuery = "";
secRepDropdownOpen = false;
@@ -551,6 +594,12 @@
companyDropdownOpen = false;
companyResults = [];
// Initialize type selection
editTypeId = (opportunity.type?.id as number) ?? null;
editTypeName = opportunity.type?.name ?? "";
typeDropdownOpen = false;
typeSearchQuery = "";
isEditing = true;
loadCWMembers();
loadEditContacts();
@@ -591,15 +640,21 @@
body.expectedCloseDate = editExpectedCloseDate || null;
// Compare primary sales rep
const origRepId = opportunity.primarySalesRep?.id ?? null;
const origRepId = (() => {
const parsed = Number(opportunity.primarySalesRep?.id);
return Number.isFinite(parsed) ? parsed : null;
})();
if (editRepId !== origRepId && editRepId !== null)
body.primarySalesRep = { id: editRepId };
body.primarySalesRep = { id: Number(editRepId) };
// Compare secondary sales rep
const origSecRepId = opportunity.secondarySalesRep?.id ?? null;
const origSecRepId = (() => {
const parsed = Number(opportunity.secondarySalesRep?.id);
return Number.isFinite(parsed) ? parsed : null;
})();
if (editSecRepId !== origSecRepId) {
body.secondarySalesRep =
editSecRepId !== null ? { id: editSecRepId } : null;
editSecRepId !== null ? { id: Number(editSecRepId) } : null;
}
// Compare contact
@@ -619,6 +674,11 @@
if (editCompanyId !== origCompanyId && editCompanyId !== null)
body.company = { id: editCompanyId };
// Compare type
const origTypeId = (opportunity.type?.id as number) ?? null;
if (editTypeId !== origTypeId && editTypeId !== null)
body.type = { id: editTypeId };
if (Object.keys(body).length === 0) {
isEditing = false;
isDirty = false;
@@ -653,7 +713,7 @@
: (opportunity?.company?.cw_Data?.allContacts ?? []);
$: matchedContact = opportunity?.contact?.id
? (allContacts.find(
(c) => String(c.cwId) === String(opportunity?.contact?.id),
(c) => String(c.cwId) === String(opportunity?.contact?.id)
) ?? allContacts[0])
: (allContacts[0] ?? null);
$: contactPhone =
@@ -692,7 +752,7 @@
}
function formatClosedBy(
closedBy: SalesOpportunity["closedBy"] | undefined,
closedBy: SalesOpportunity["closedBy"] | undefined
): string | null {
if (!closedBy) return null;
if (typeof closedBy === "string") return closedBy;
@@ -739,6 +799,23 @@
if (heat === "heat-hot") s += " box-shadow: 0 0 4px rgba(239,68,68,0.6);";
return s;
}
function repDisplayName(
rep?: {
name?: string;
identifier?: string;
} | null
): string | null {
if (!rep) return null;
return rep.name || rep.identifier || null;
}
function repInitial(
rep?: { name?: string; identifier?: string } | null
): string {
const display = repDisplayName(rep);
return display ? display.charAt(0).toUpperCase() : "?";
}
/** Map probability percent to a tier for styling */
function probabilityTier(percent: number): string {
if (percent >= 75) return "prob-high";
@@ -1493,6 +1570,107 @@
/>
</div>
<!-- Type dropdown -->
<div class="opp-edit-group">
<span class="opp-edit-label">Opportunity Type</span>
<div class="opp-edit-dropdown" data-dropdown-id="type">
<button
type="button"
class="opp-edit-dropdown-trigger"
bind:this={typeTriggerEl}
on:click={() => {
typeDropdownOpen = !typeDropdownOpen;
repDropdownOpen = false;
companyDropdownOpen = false;
secRepDropdownOpen = false;
contactDropdownOpen = false;
siteDropdownOpen = false;
if (typeDropdownOpen) positionDropdown(typeTriggerEl);
}}
disabled={isSaving}
>
<span
class="opp-edit-dropdown-value"
class:placeholder={!editTypeName}
>
{editTypeName || "Select type…"}
</span>
<svg
class="opp-edit-dropdown-chevron"
class:open={typeDropdownOpen}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{#if typeDropdownOpen}
<div
class="opp-edit-dropdown-panel"
style="position:fixed; top:{dropdownPos.top}px; left:{dropdownPos.left}px; width:{dropdownPos.width}px;"
>
<div class="opp-edit-dropdown-search-wrap">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<circle cx="11" cy="11" r="8" /><line
x1="21"
y1="21"
x2="16.65"
y2="16.65"
/>
</svg>
<input
type="text"
class="opp-edit-dropdown-search"
placeholder="Search types…"
bind:value={typeSearchQuery}
on:click|stopPropagation
/>
</div>
<div class="opp-edit-dropdown-options">
{#if opportunityTypes.length === 0}
<div class="opp-edit-dropdown-empty">No types available</div>
{:else if filteredTypes.length === 0}
<div class="opp-edit-dropdown-empty">No types found</div>
{:else}
{#each filteredTypes as t (t.id)}
<button
type="button"
class="opp-edit-dropdown-item"
class:selected={editTypeId === t.id}
on:mousedown|preventDefault={() => selectType(t)}
>
<span class="opp-edit-dropdown-item-label">{t.name}</span>
{#if editTypeId === t.id}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="13"
height="13"
><polyline points="20 6 9 17 4 12" /></svg
>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Description (large textarea at bottom — takes remaining space) -->
<div class="opp-edit-group opp-edit-group-grow">
<label class="opp-edit-label" for="edit-description"
@@ -1682,27 +1860,25 @@
{opportunity.type.wonFlag ? "Won" : "Lost"}
</span>
{/if}
{#if opportunity.rating?.name && !isClosedOpportunity && !opportunity.status?.name
{#if opportunity.interest && !isClosedOpportunity && !opportunity.status?.name
?.toLowerCase()
.includes("cancel")}
<span
class="opp-rating-badge {ratingHeatClass(
opportunity.rating.name,
)}"
class="opp-rating-badge {ratingHeatClass(opportunity.interest)}"
>
<span class="opp-heat-dots">
{#each [1, 2, 3] as level}
<span style={getDotStyle(level, opportunity.rating.name)}
<span style={getDotStyle(level, opportunity.interest)}
></span>
{/each}
</span>
{opportunity.rating.name}
{opportunity.interest}
</span>
{/if}
{#if opportunity.probability?.percent != null}
<span
class="opp-probability-badge {probabilityTier(
opportunity.probability.percent,
opportunity.probability.percent
)}"
>
<svg
@@ -1755,36 +1931,32 @@
{/if}
<!-- ── 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 class="opp-byline">
<div class="opp-byline-rep">
<div class="opp-byline-avatar">
{repInitial(opportunity.primarySalesRep)}
</div>
<div class="opp-byline-info">
<span class="opp-byline-name"
>{repDisplayName(opportunity.primarySalesRep) ?? "—"}</span
>
<span class="opp-byline-role">Primary Rep</span>
</div>
</div>
{/if}
{#if repDisplayName(opportunity.secondarySalesRep)}
<div class="opp-byline-rep">
<div class="opp-byline-avatar secondary">
{repInitial(opportunity.secondarySalesRep)}
</div>
<div class="opp-byline-info">
<span class="opp-byline-name"
>{repDisplayName(opportunity.secondarySalesRep)}</span
>
<span class="opp-byline-role">Secondary Rep</span>
</div>
</div>
{/if}
</div>
<div class="opp-sidebar-divider"></div>
@@ -1,8 +1,10 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import { clientFetch } from "$lib/client-fetch";
import type {
SalesOpportunity,
OpportunityActivity,
WorkflowHistoryEntry,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import type {
@@ -20,14 +22,30 @@
const dispatch = createEventDispatcher<{
selectProduct: number;
switchTab: string;
viewQuote: string;
}>();
export let opportunity: SalesOpportunity | null;
export let opportunityId: string;
export let notes: OpportunityNote[];
export let contacts: OpportunityContact[];
export let products: OpportunityProduct[];
export let permissions: PermissionMap = {};
let workflowHistory: WorkflowHistoryEntry[] = [];
onMount(() => {
clientFetch<{ data: { activities: WorkflowHistoryEntry[] } }>(
`/sales/opportunity/${opportunityId}/workflow/history`,
)
.then((json) => {
workflowHistory = json.data?.activities ?? [];
})
.catch(() => {
// silently ignore — timeline falls back to raw activities
});
});
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
@@ -108,6 +126,11 @@
dotClass?: string;
textClass?: string;
highlight?: boolean;
// Activity-specific rich data
activity?: OpportunityActivity;
quoteId?: string | null;
closed?: boolean | null;
closedAt?: string | null;
};
// Collapse threshold: show first N and last N when total > MAX
@@ -159,28 +182,83 @@
return map[type ?? ""] ?? "";
}
function parseStatusTransition(
notes: string | undefined,
): { from: string; to: string } | null {
if (!notes) return null;
const match = notes.match(/\[(\w+)\s*→\s*(\w+)\]/);
if (match) return { from: match[1], to: match[2] };
return null;
}
function isSystemActivity(activity: OpportunityActivity): boolean {
return !activity.assignTo?.name && !activity.cwEnteredBy;
}
function assignedDisplay(activity: OpportunityActivity): string {
if (isSystemActivity(activity)) return "System";
return activity.assignTo?.name ?? activity.cwEnteredBy ?? "Unknown";
}
function isOpenActivity(activity: OpportunityActivity): boolean {
if (activity.closed != null) return !activity.closed;
if (activity.status?.id != null) return activity.status.id !== 2;
return !activity.dateEnd;
}
function activityBadgeClass(type: string | null): string {
const map: Record<string, string> = {
"Opportunity Created": "at-type-created",
"Opportunity Setup": "at-type-setup",
"Opportunity Review": "at-type-review",
"Quote Sent": "at-type-sent",
"Quote Confirmed": "at-type-confirmed",
"Quote Sent & Confirmed": "at-type-sent-confirmed",
Revision: "at-type-revision",
Finalized: "at-type-finalized",
Converted: "at-type-converted",
"Quote Generated": "at-type-generated",
};
return map[type ?? ""] ?? "at-type-default";
}
function activityIcon(type: string | null): string {
switch (type) {
case "Opportunity Created":
return "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z";
case "Opportunity Setup":
return "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z";
case "Opportunity Review":
return "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 100 6 3 3 0 000-6z";
case "Quote Sent":
return "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z";
case "Quote Confirmed":
return "M22 11.08V12a10 10 0 11-5.93-9.14M22 4L12 14.01l-3-3";
case "Quote Sent & Confirmed":
return "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z";
case "Revision":
return "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z";
case "Finalized":
return "M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11";
case "Converted":
return "M17 1l4 4-4 4M3 11V9a4 4 0 014-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 01-4 4H3";
case "Quote Generated":
return "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8";
default:
return "M12 2v20M17 7l-5 5-5-5M2 12h20M7 17l5-5 5 5";
}
}
$: timeline = (() => {
const entries: TimelineEntry[] = [];
// Milestones from opportunity dates
// Key milestone dates
if (opportunity?.createdAt)
entries.push({
label: "Created",
date: opportunity.createdAt,
kind: "milestone",
});
if (opportunity?.dateBecameLead)
entries.push({
label: "Became Lead",
date: opportunity.dateBecameLead,
kind: "milestone",
});
if (opportunity?.pipelineChangeDate)
entries.push({
label: "Pipeline Changed",
date: opportunity.pipelineChangeDate,
kind: "milestone",
});
if (opportunity?.expectedCloseDate)
entries.push({
label: "Expected Close",
@@ -188,26 +266,43 @@
kind: "milestone",
highlight: true,
});
if (opportunity?.closedDate)
entries.push({
label: "Closed",
date: opportunity.closedDate,
kind: "milestone",
});
// Workflow activities
for (const a of opportunity?.activities ?? []) {
const optimaType = getOptimaType(a);
if (!optimaType) continue;
const date = a.cwDateEntered ?? a.dateStart ?? "";
if (!date) continue;
entries.push({
label: optimaType,
date,
kind: "activity",
dotClass: activityDotClass(optimaType),
textClass: activityTextClass(optimaType),
});
// Workflow activities — prefer enriched history, fall back to raw activities
if (workflowHistory.length > 0) {
for (const h of workflowHistory) {
const date =
h.activity.cwDateEntered ?? h.activity.dateStart ?? "";
if (!date) continue;
entries.push({
label: h.optimaType,
date,
kind: "activity",
dotClass: activityDotClass(h.optimaType),
textClass: activityTextClass(h.optimaType),
activity: h.activity,
quoteId: h.quoteId ?? null,
closed: h.closed ?? null,
closedAt: h.closedAt ?? null,
});
}
} else {
for (const a of opportunity?.activities ?? []) {
const optimaType = getOptimaType(a);
if (!optimaType) continue;
const date = a.cwDateEntered ?? a.dateStart ?? "";
if (!date) continue;
entries.push({
label: optimaType,
date,
kind: "activity",
dotClass: activityDotClass(optimaType),
textClass: activityTextClass(optimaType),
activity: a,
quoteId: null,
closed: null,
closedAt: null,
});
}
}
// Sort chronologically (oldest first)
@@ -230,7 +325,8 @@
$: daysUntilClose = (() => {
if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null;
const raw = opportunity.expectedCloseDate;
const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00");
const dateOnly = raw.split("T")[0];
const close = new Date(dateOnly + "T00:00:00");
const now = new Date();
const closeDay = new Date(
close.getFullYear(),
@@ -276,6 +372,33 @@
let popoverY = 0;
let popoverTimeout: ReturnType<typeof setTimeout> | null = null;
// ── Timeline entry popover state ──
let hoveredEntry: TimelineEntry | null = null;
let entryPopoverX = 0;
let entryPopoverY = 0;
let entryPopoverTimeout: ReturnType<typeof setTimeout> | null = null;
let timelineSectionEl: HTMLElement | null = null;
function showEntryPopover(e: MouseEvent, entry: TimelineEntry) {
if (entry.kind !== "activity" || !entry.activity) return;
if (entryPopoverTimeout) clearTimeout(entryPopoverTimeout);
hoveredEntry = entry;
const sectionRect = timelineSectionEl?.getBoundingClientRect();
const entryRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
entryPopoverX = sectionRect ? sectionRect.left : entryRect.left;
entryPopoverY = entryRect.top - 8;
}
function hideEntryPopover() {
entryPopoverTimeout = setTimeout(() => {
hoveredEntry = null;
}, 150);
}
function keepEntryPopover() {
if (entryPopoverTimeout) clearTimeout(entryPopoverTimeout);
}
function showPopover(e: MouseEvent, product: OpportunityProduct) {
if (popoverTimeout) clearTimeout(popoverTimeout);
hoveredProduct = product;
@@ -361,7 +484,7 @@
<!-- ═══ Two-column layout: Timeline + Forecast ═══ -->
<div class="ov-main-grid">
<!-- Left: Timeline -->
<div class="ov-section ov-timeline-section">
<div class="ov-section ov-timeline-section" bind:this={timelineSectionEl}>
<h3 class="ov-section-title">
<svg
viewBox="0 0 24 24"
@@ -386,7 +509,7 @@
{#if timelineCollapsed && i === TIMELINE_EDGE}
<!-- Collapsed gap -->
<button
class="ov-timeline-gap"
class="ov-tl-gap-btn"
on:click={() => dispatch("switchTab", "Activity")}
title="View all in Activity tab"
>
@@ -403,28 +526,132 @@
</button>
{/if}
<div
class="ov-timeline-item"
class:last={i === visibleTimeline.length - 1}
class:highlight={entry.highlight}
class:ov-activity-item={entry.kind === "activity"}
class="ov-tl-entry"
on:mouseenter={(e) => showEntryPopover(e, entry)}
on:mouseleave={hideEntryPopover}
role="listitem"
>
<div
class="ov-timeline-dot {entry.kind === 'activity'
? (entry.dotClass ?? '')
: ''}"
class:highlight={entry.highlight}
></div>
<div class="ov-timeline-content">
<span
class="ov-timeline-label {entry.kind === 'activity'
? (entry.textClass ?? '')
: ''}">{entry.label}</span
>
<span class="ov-timeline-date"
>{entry.kind === "activity"
? formatDateTime(entry.date)
: formatDate(entry.date)}</span
<!-- Connector -->
<div class="ov-tl-connector">
<div
class="ov-tl-dot {entry.kind === 'activity'
? (entry.dotClass ?? 'ov-dot-default')
: 'ov-tl-dot-milestone'}"
class:ov-tl-dot-highlight={entry.highlight &&
entry.kind !== "activity"}
>
{#if entry.kind === "activity"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d={activityIcon(entry.label)} />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/>
</svg>
{/if}
</div>
{#if i < visibleTimeline.length - 1 && !(timelineCollapsed && i === TIMELINE_EDGE - 1)}
<div class="ov-tl-line"></div>
{/if}
</div>
<!-- Content -->
<div class="ov-tl-body">
{#if entry.kind === "activity" && entry.activity}
{@const act = entry.activity}
{@const open =
entry.closed != null
? !entry.closed
: isOpenActivity(act)}
<!-- Header row: badges -->
<div class="at-content-header">
<span
class="at-type-badge {activityBadgeClass(entry.label)}"
>{entry.label}</span
>
{#if open}
<span class="at-open-badge">Open</span>
{/if}
</div>
<!-- Meta row: user + timestamp only -->
<div class="at-meta ov-tl-meta">
<span
class="at-user"
class:at-system={isSystemActivity(act)}
>
{#if isSystemActivity(act)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
ry="2"
/><line x1="8" y1="21" x2="16" y2="21" /><line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<path
d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"
/><circle cx="12" cy="7" r="4" />
</svg>
{/if}
{assignedDisplay(act)}
</span>
{#if act.cwDateEntered}
<span class="at-timestamp"
>{formatDateTime(act.cwDateEntered)}</span
>
{:else if act.dateStart}
<span class="at-timestamp"
>{formatDateTime(act.dateStart)}</span
>
{/if}
</div>
{:else}
<!-- Milestone entry -->
<span
class="ov-tl-milestone"
class:ov-tl-milestone-highlight={entry.highlight}
>{entry.label}</span
>
<span class="ov-tl-date">{formatDate(entry.date)}</span>
{/if}
</div>
</div>
{/each}
@@ -433,6 +660,154 @@
<p class="ov-empty-note">No timeline events yet.</p>
{/if}
<!-- Timeline entry popover -->
{#if hoveredEntry && hoveredEntry.activity}
{@const pAct = hoveredEntry.activity}
{@const pTransition = parseStatusTransition(pAct.notes)}
{@const pOpen =
hoveredEntry.closed != null
? !hoveredEntry.closed
: isOpenActivity(pAct)}
<div
class="ov-entry-popover"
style="top: {entryPopoverY}px; left: {entryPopoverX}px;"
on:mouseenter={keepEntryPopover}
on:mouseleave={hideEntryPopover}
role="tooltip"
>
<!-- Header -->
<div class="at-content-header">
<span
class="at-type-badge {activityBadgeClass(hoveredEntry.label)}"
>{hoveredEntry.label}</span
>
{#if pTransition}
<span class="at-transition-pill">
<span class="at-transition-from">{pTransition.from}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="10"
height="10"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
<span class="at-transition-to">{pTransition.to}</span>
</span>
{/if}
{#if pOpen}
<span class="at-open-badge">Open</span>
{/if}
</div>
<!-- Name -->
{#if pAct.name}
<p class="at-name">{pAct.name.replace(/^\[Workflow\]\s*/, "")}</p>
{/if}
<!-- Notes -->
{#if pAct.notes}
<p class="at-notes">{pAct.notes}</p>
{/if}
<!-- Meta -->
<div class="at-meta">
<span class="at-user" class:at-system={isSystemActivity(pAct)}>
{#if isSystemActivity(pAct)}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line
x1="8"
y1="21"
x2="16"
y2="21"
/><line x1="12" y1="17" x2="12" y2="21" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
{/if}
{assignedDisplay(pAct)}
</span>
{#if pAct.cwDateEntered}
<span class="at-timestamp">{formatDateTime(pAct.cwDateEntered)}</span>
{:else if pAct.dateStart}
<span class="at-timestamp">{formatDateTime(pAct.dateStart)}</span>
{/if}
{#if hoveredEntry.closedAt}
<span class="at-timestamp at-closed-at"
>Closed: {formatDateTime(hoveredEntry.closedAt)}</span
>
{:else if pAct.closedAt}
<span class="at-timestamp at-closed-at"
>Closed: {formatDateTime(pAct.closedAt)}</span
>
{:else if pAct.dateEnd}
<span class="at-timestamp at-closed-at"
>Closed: {formatDateTime(pAct.dateEnd)}</span
>
{/if}
</div>
<!-- Quote link -->
{#if hoveredEntry.quoteId && hoveredEntry.label === "Quote Generated"}
<button
class="at-quote-link"
on:click={() =>
hoveredEntry?.quoteId &&
dispatch("viewQuote", hoveredEntry.quoteId)}
title="View quote"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<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" />
</svg>
<span class="at-quote-link-label">{hoveredEntry.quoteId}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
class="at-quote-link-arrow"
>
<line x1="5" y1="12" x2="19" y2="12" /><polyline
points="12 5 19 12 12 19"
/>
</svg>
</button>
{/if}
</div>
{/if}
<!-- Quick Stats under Timeline -->
<div class="ov-quick-stats">
<div class="ov-quick-stat">
@@ -27,6 +27,7 @@
export let permissions: PermissionMap = {} as PermissionMap;
export let isClosedOpportunity: boolean = false;
export let salesTaxRate: number | null = null;
export let taxCodeDescription: string | null = null;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
@@ -92,7 +93,7 @@
try {
const result = await optima.sales.fetchProducts(
accessToken,
opportunityId,
opportunityId
);
if (result?.data) {
products = result.data;
@@ -110,27 +111,37 @@
const laborItems = items.filter((item) => item.identifier === "LABOR");
const specialOrderItems = items.filter(
(item) => item.identifier === "SPECIAL-ORDER",
(item) => item.identifier === "SPECIAL-ORDER"
);
const standardItems = items.filter(
(item) =>
item.identifier !== "SPECIAL-ORDER" && item.identifier !== "LABOR",
item.identifier !== "SPECIAL-ORDER" && item.identifier !== "LABOR"
);
isAddingProduct = true;
addProductError = "";
try {
const newProducts: OpportunityProduct[] = [];
// Add standard catalog items through generic endpoint
if (standardItems.length > 0) {
await Promise.all(
const results = await Promise.all(
standardItems.map((item) =>
optima.sales.addProduct(
accessToken!,
opportunityId,
buildProductBody(item),
),
),
buildProductBody(item)
)
)
);
for (const result of results) {
if (result?.data) {
const items = Array.isArray(result.data)
? result.data
: [result.data];
newProducts.push(...items);
}
}
}
if (laborItems.length > 0) {
@@ -163,11 +174,34 @@
};
});
await Promise.all(
const results = await Promise.all(
payload.map((body) =>
optima.sales.addLabor(accessToken!, opportunityId, body),
),
optima.sales.addLabor(accessToken!, opportunityId, body)
)
);
for (const result of results) {
const d = result?.data;
if (d) {
const revenue = d.revenue ?? 0;
const cost = d.cost ?? 0;
newProducts.push({
id: d.forecastDetailId ?? d.id,
forecastDescription: d.description,
productDescription: d.description,
productClass: "Service",
forecastType: "Product",
quantity: d.quantity,
revenue,
cost,
margin: revenue - cost,
catalogItem: d.catalogItem,
taxableFlag: d.taxableFlag,
customerDescription: d.customerDescription,
procurementNotes: d.procurementNotes,
productNarrative: d.productNarrative,
});
}
}
}
// Add special-order items through dedicated endpoint
@@ -183,15 +217,42 @@
productNarrative: item.productNarrative,
}));
await Promise.all(
const results = await Promise.all(
payload.map((body) =>
optima.sales.addSpecialOrder(accessToken!, opportunityId, body),
),
optima.sales.addSpecialOrder(accessToken!, opportunityId, body)
)
);
for (const result of results) {
const items = Array.isArray(result?.data)
? result.data
: result?.data
? [result.data]
: [];
for (const d of items) {
const revenue = d.revenue ?? d.price ?? 0;
const cost = d.cost ?? 0;
newProducts.push({
id: d.forecastDetailId ?? d.id,
forecastDescription: d.description,
productDescription: d.productDescription ?? d.description,
quantity: d.quantity,
revenue,
cost,
margin: revenue - cost,
taxableFlag: d.taxableFlag,
customerDescription: d.customerDescription,
procurementNotes: d.procurementNotes,
productNarrative: d.productNarrative,
});
}
}
}
// Re-fetch the full product list so everything is in sync
await refreshProducts();
// Append newly created products to the list immediately
if (newProducts.length > 0) {
products = [...products, ...newProducts];
dispatch("productsChanged", products);
}
showAddProductModal = false;
} catch (err) {
@@ -292,9 +353,9 @@
Math.max(
selectedProduct.quantityCancelled ??
(selectedProduct.cancellationType === "full" ? maxQty : 0),
0,
0
),
maxQty,
maxQty
);
cancellationForm = {
@@ -334,7 +395,7 @@
accessToken,
opportunityId,
selectedProduct.id,
payload,
payload
);
await refreshProducts();
selectedProduct =
@@ -372,7 +433,7 @@
await optima.sales.deleteProduct(
accessToken,
opportunityId,
selectedProduct.id,
selectedProduct.id
);
showDeleteModal = false;
selectedProduct = null;
@@ -420,7 +481,7 @@
console.log(
"[EditProduct] Description payload:",
updates.productDescription,
updates.productDescription
);
isSavingEdit = true;
@@ -430,7 +491,7 @@
accessToken,
opportunityId,
selectedProduct.id,
updates,
updates
);
await refreshProducts();
selectedProduct =
@@ -540,7 +601,7 @@
$: cancelledProducts = [...products]
.filter((p) => isCancelled(p))
.sort(
(a, b) => (a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity),
(a, b) => (a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity)
);
// Initialize when the products prop changes (but NOT when activeProducts is reassigned by drag)
@@ -566,7 +627,7 @@
// Fallback: CW sequenceNumber ordering
sorted = [...incoming].sort(
(a, b) =>
(a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity),
(a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity)
);
}
orderedProducts = sorted;
@@ -585,7 +646,7 @@
// ── Computed totals ──
$: totalRevenue = activeProducts.reduce(
(sum, p) => sum + (p.revenue ?? 0),
0,
0
);
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0);
@@ -593,6 +654,14 @@
$: markupPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0;
$: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0;
$: taxRate = (salesTaxRate ?? 0) / 100;
$: taxLabel =
taxCodeDescription != null
? salesTaxRate && salesTaxRate > 0
? `Tax (${taxCodeDescription} ${salesTaxRate.toFixed(2)}%)`
: `Tax (${taxCodeDescription})`
: salesTaxRate
? `Tax (${salesTaxRate.toFixed(2)}%)`
: "Tax";
$: taxableRevenue = activeProducts
.filter((p) => p.taxableFlag)
.reduce((sum, p) => sum + (p.revenue ?? 0), 0);
@@ -745,7 +814,7 @@
const result = await optima.sales.sequenceProducts(
accessToken,
opportunityId,
orderedIds,
orderedIds
);
const idMap: Record<string, number> | undefined = result?.data?.idMap;
@@ -780,7 +849,7 @@
function discardChanges() {
resetDragState();
const sorted = [...products].sort(
(a, b) => (a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity),
(a, b) => (a.sequenceNumber ?? Infinity) - (b.sequenceNumber ?? Infinity)
);
activeProducts = sorted.filter((p) => !isCancelled(p));
originalOrderIds = activeProducts.map((p) => p.id);
@@ -1012,7 +1081,14 @@
{#if totalTax > 0}
<div class="kpi-card">
<div class="kpi-icon tax">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<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" />
@@ -1021,7 +1097,7 @@
</svg>
</div>
<div class="kpi-content">
<span class="kpi-label">Tax ({salesTaxRate}%)</span>
<span class="kpi-label">{taxLabel}</span>
<span class="kpi-value">{formatCurrency(totalTax)}</span>
</div>
</div>
@@ -1391,15 +1467,15 @@
<div
class="card-margin-fill {markupHealthColor(
p.revenue,
p.margin,
p.margin
)}"
class:from-right={isNegativeMarkup(
p.revenue,
p.margin,
p.margin
)}
style="width: {markupBarWidthPct(
p.revenue,
p.margin,
p.margin
)}%"
></div>
{#if rawMarkupPct(p.revenue, p.margin) > 100}
@@ -1407,7 +1483,7 @@
class="card-margin-fill super-blue"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
100,
100
)}%"
></div>
{/if}
@@ -1416,7 +1492,7 @@
class="card-margin-fill super-indigo"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
200,
200
)}%"
></div>
{/if}
@@ -1425,7 +1501,7 @@
class="card-margin-fill super-violet"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
300,
300
)}%"
></div>
{/if}
@@ -1437,15 +1513,15 @@
<div
class="card-margin-fill {marginHealthColor(
p.revenue,
p.margin,
p.margin
)}"
class:from-right={isNegativeMargin(
p.revenue,
p.margin,
p.margin
)}
style="width: {marginBarWidthPct(
p.revenue,
p.margin,
p.margin
)}%"
></div>
{#if rawMarginPct(p.revenue, p.margin) > 100}
@@ -1453,7 +1529,7 @@
class="card-margin-fill super-blue"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
100,
100
)}%"
></div>
{/if}
@@ -1462,7 +1538,7 @@
class="card-margin-fill super-indigo"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
200,
200
)}%"
></div>
{/if}
@@ -1471,7 +1547,7 @@
class="card-margin-fill super-violet"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
300,
300
)}%"
></div>
{/if}
@@ -1944,7 +2020,7 @@
</div>
{#if selectedProduct.catalogItem?.identifier}
<div class="detail-field">
<span class="detail-field-label">Catalog Item</span>
<span class="detail-field-label">Product ID</span>
<span class="detail-field-value mono"
>{selectedProduct.catalogItem.identifier}</span
>
@@ -2052,7 +2128,7 @@
>{#if isEditing}
{formatCurrency(
(parseFloat(editForm.unitPrice) || 0) -
(parseFloat(editForm.unitCost) || 0),
(parseFloat(editForm.unitCost) || 0)
)}
{:else}
{formatCurrency(unitMargin(selectedProduct))}
@@ -2130,15 +2206,15 @@
<div
class="detail-margin-fill {markupHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}"
class:from-right={isNegativeMarkup(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}
style="width: {markupBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}%"
></div>
{#if rawMarkupPct(selectedProduct.revenue, selectedProduct.margin) > 100}
@@ -2147,9 +2223,9 @@
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
100,
100
)}%"
></div>
{/if}
@@ -2159,9 +2235,9 @@
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
200,
200
)}%"
></div>
{/if}
@@ -2171,9 +2247,9 @@
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
300,
300
)}%"
></div>
{/if}
@@ -2195,15 +2271,15 @@
<div
class="detail-margin-fill {marginHealthColor(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}"
class:from-right={isNegativeMargin(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}
style="width: {marginBarWidthPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
)}%"
></div>
{#if rawMarginPct(selectedProduct.revenue, selectedProduct.margin) > 100}
@@ -2212,9 +2288,9 @@
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
100,
100
)}%"
></div>
{/if}
@@ -2224,9 +2300,9 @@
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
200,
200
)}%"
></div>
{/if}
@@ -2236,9 +2312,9 @@
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
selectedProduct.margin
),
300,
300
)}%"
></div>
{/if}
@@ -2287,7 +2363,9 @@
rows="3"
></textarea>
{:else}
<span class="detail-field-value"
<span
class="detail-field-value"
style="white-space: pre-wrap;"
>{selectedProduct.productNarrative || "—"}</span
>
{/if}
@@ -2302,7 +2380,9 @@
rows="3"
></textarea>
{:else}
<span class="detail-field-value"
<span
class="detail-field-value"
style="white-space: pre-wrap;"
>{selectedProduct.procurementNotes || "—"}</span
>
{/if}
@@ -2424,7 +2504,7 @@
>
<span class="detail-field-value"
>{formatCurrency(
selectedProduct.recurringRevenue,
selectedProduct.recurringRevenue
)}</span
>
</div>
@@ -2433,7 +2513,7 @@
<span class="detail-field-label">Recurring Cost</span>
<span class="detail-field-value"
>{formatCurrency(
selectedProduct.recurringCost,
selectedProduct.recurringCost
)}</span
>
</div>
@@ -2580,7 +2660,7 @@
<p class="cancel-modal-copy">
Set cancelled quantity between 0 and {maxCancellationQty(
selectedProduct,
selectedProduct
)}.
</p>
@@ -39,7 +39,7 @@
// Determine initial selection: prefer initialQuoteId match, fall back to first quote
const initialMatch = initialQuoteId
? initialQuotes.find(
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId,
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId
)
: null;
console.log(
@@ -48,7 +48,7 @@
"quotes:",
initialQuotes.map((q) => ({ id: q.id, fileName: q.quoteFileName })),
"match:",
initialMatch?.id,
initialMatch?.id
);
let selectedQuote: CommittedQuote | null =
initialMatch ?? (initialQuotes.length > 0 ? initialQuotes[0] : null);
@@ -56,7 +56,7 @@
// Auto-select quote by ID when navigating from activity tab (for post-mount updates)
$: if (initialQuoteId && quotes.length > 0) {
const match = quotes.find(
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId,
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId
);
if (match) {
selectedQuote = match;
@@ -77,11 +77,23 @@
let quotePreviewQuoteId: string | null = null;
// ── View mode ──
type ViewMode = "list" | "preview" | "logs";
type ViewMode = "list" | "preview" | "logs" | "narrative";
let viewMode: ViewMode = initialQuotes.length > 0 ? "list" : "preview";
// Show Quotes sub-tab only when there are quotes
$: showQuotesSubTab = quotes.length > 0;
// Show the quotes sub-tabs whenever this section is open.
$: showQuotesSubTab = true;
$: canEditQuoteNarrative = permissions["sales.opportunity.update"] !== false;
let quoteNarrative = "";
let quoteNarrativeSaved = ""; // Track the last saved value
let quoteNarrativeLoading = false;
let quoteNarrativeSaving = false;
let quoteNarrativeLoaded = false;
let quoteNarrativeError = "";
let quoteNarrativeSuccess = "";
let quoteNarrativeAutoSaveTimeout: ReturnType<typeof setTimeout> | null =
null;
// ── Live preview state ──
let quotePreviewOptions = {
@@ -100,6 +112,10 @@
let isConnected = false;
let connectionError = "";
// Tracks the token currently used for the socket (may be refreshed independently of the prop)
let socketAccessToken: string | null = null;
let isRefreshingToken = false;
let livePreviewPdfUrl: string | null = null;
let livePreviewObjectUrl: string | null = null;
@@ -114,19 +130,19 @@
$: allProducts = selectedQuoteDetail?.quoteRegenData?.products ?? [];
$: activeProducts = allProducts.filter((p) => p.cancellationType !== "full");
$: cancelledProducts = allProducts.filter(
(p) => p.cancellationType === "full",
(p) => p.cancellationType === "full"
);
$: totalRevenue = activeProducts.reduce(
(sum, p) => sum + (p.revenue ?? 0),
0,
0
);
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = totalRevenue - totalCost;
$: totalItems = activeProducts.length;
$: totalQty = activeProducts.reduce(
(sum, p) => sum + (p.effectiveQuantity ?? p.quantity ?? 0),
0,
0
);
let cancelledExpanded = false;
@@ -212,11 +228,103 @@
}
}
async function loadQuoteNarrative() {
if (
!accessToken ||
!opportunityId ||
quoteNarrativeLoaded ||
quoteNarrativeLoading
)
return;
quoteNarrativeLoading = true;
quoteNarrativeError = "";
try {
const result = await sales.fetchQuoteNarrative(
accessToken,
opportunityId
);
quoteNarrative = result.data?.quoteNarrative ?? "";
quoteNarrativeSaved = quoteNarrative;
quoteNarrativeLoaded = true;
} catch (err: unknown) {
quoteNarrativeLoaded = true;
quoteNarrativeError =
err instanceof Error ? err.message : "Failed to load quote narrative";
} finally {
quoteNarrativeLoading = false;
}
}
function selectQuote(q: CommittedQuote) {
selectedQuote = q;
loadQuotePreview(q.id);
}
$: hasNarrativeChanges = quoteNarrative !== quoteNarrativeSaved;
function scheduleAutoSave() {
if (!canEditQuoteNarrative) return;
if (quoteNarrativeAutoSaveTimeout)
clearTimeout(quoteNarrativeAutoSaveTimeout);
quoteNarrativeAutoSaveTimeout = setTimeout(() => {
saveQuoteNarrative(true);
}, 2000);
}
async function saveQuoteNarrative(isAutoSave = false) {
if (
!accessToken ||
!opportunityId ||
quoteNarrativeSaving ||
!canEditQuoteNarrative
)
return;
quoteNarrativeSaving = true;
quoteNarrativeError = "";
if (!isAutoSave) quoteNarrativeSuccess = "";
try {
const result = await sales.updateQuoteNarrative(
accessToken,
opportunityId,
quoteNarrative.trim().length > 0 ? quoteNarrative : null
);
quoteNarrative = result.data?.quoteNarrative ?? quoteNarrative;
quoteNarrativeSaved = quoteNarrative;
quoteNarrativeLoaded = true;
if (!isAutoSave) {
quoteNarrativeSuccess = "Quote narrative saved to CW.";
setTimeout(() => (quoteNarrativeSuccess = ""), 3000);
}
publishPreviewPayload();
} catch (err: unknown) {
if (!isAutoSave) {
quoteNarrativeError =
err instanceof Error ? err.message : "Failed to save quote narrative";
}
} finally {
quoteNarrativeSaving = false;
}
}
function handleNarrativeKeydown(event: KeyboardEvent) {
// Ctrl+S or Cmd+S to save
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
if (hasNarrativeChanges) {
saveQuoteNarrative();
}
}
}
function getCharCount(text: string): { chars: number; words: number } {
const trimmed = text.trim();
const chars = trimmed.length;
const words = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
return { chars, words };
}
$: narrativeStats = getCharCount(quoteNarrative);
function cleanupQuotePreview() {
if (quotePreviewObjectUrl) {
URL.revokeObjectURL(quotePreviewObjectUrl);
@@ -236,7 +344,7 @@
const result = await sales.previewQuote(
accessToken,
opportunityId,
quoteId,
quoteId
);
const base64 = result.data?.contentBase64;
if (!base64) {
@@ -273,12 +381,12 @@
accessToken,
opportunityId,
selectedQuoteDetail.id,
"download",
"download"
);
const { contentBase64, quoteFileName, mimeType } = result.data;
const cleaned = contentBase64.replace(
/^data:application\/pdf;base64,/,
"",
""
);
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
@@ -314,12 +422,12 @@
accessToken,
opportunityId,
selectedQuoteDetail.id,
"print",
"print"
);
const { contentBase64 } = result.data;
const cleaned = contentBase64.replace(
/^data:application\/pdf;base64,/,
"",
""
);
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
@@ -360,6 +468,11 @@
viewMode = "list";
}
function switchToNarrative() {
viewMode = "narrative";
loadQuoteNarrative();
}
function switchToLogs() {
viewMode = "logs";
loadDownloadLogs();
@@ -372,7 +485,7 @@
try {
const result = await sales.fetchQuoteDownloads(
accessToken,
opportunityId,
opportunityId
);
downloadLogs = result.data ?? [];
logsLoaded = true;
@@ -486,51 +599,17 @@
isConnected = false;
}
async function handleCommitQuote() {
if (!accessToken || !opportunityId || isCommitting) return;
isCommitting = true;
commitError = "";
commitSuccess = "";
try {
const result = await sales.commitQuote(accessToken, opportunityId, {
lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives,
});
commitSuccess = result.message || "Quote created successfully!";
// Reload quotes (basic + detail) and switch to list view
detailLoaded = false;
await loadQuotes();
await loadQuoteDetails();
if (result.data) {
selectedQuote =
quotes.find((q) => q.id === result.data.id) ?? quotes[0] ?? null;
}
viewMode = "list";
setTimeout(() => (commitSuccess = ""), 5000);
} catch (err: unknown) {
commitError =
err instanceof Error ? err.message : "Failed to create quote";
setTimeout(() => (commitError = ""), 5000);
} finally {
isCommitting = false;
}
}
onMount(() => {
// Lazy-load detailed quote data (regen data & params) now that the tab is active
loadQuoteDetails();
// Set up live preview socket (only if user has preview permission)
if (!accessToken || !opportunityId || !canPreviewQuote) return;
function setupSocket() {
if (!socketAccessToken || !opportunityId || !canPreviewQuote) return;
const base = PUBLIC_API_URL || "";
connectionError = "";
socket = io(`${base}/secure`, {
transports: ["websocket"],
reconnection: false, // We manage reconnection manually to avoid retrying with a stale token
auth: {
authorization: `Bearer ${accessToken}`,
authorization: `Bearer ${socketAccessToken}`,
},
rejectUnauthorized: false,
});
@@ -563,26 +642,80 @@
});
publishPreviewPayload();
},
}
);
});
socket.on("connect_error", (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.toLowerCase().includes("unauthorized")) {
attemptTokenRefreshAndReconnect();
return;
}
isConnected = false;
connectionError =
err instanceof Error ? err.message : "Socket connection error";
connectionError = msg || "Socket connection error";
});
// Server emits this before disconnecting an expired/invalidated session
socket.on("secure:session:expired", () => {
attemptTokenRefreshAndReconnect();
});
socket.on("disconnect", () => {
isConnected = false;
});
socket.on("error", (err: unknown) => {
connectionError =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "Live quote preview socket error";
});
socket.on(
"opp:live_quote_preview:error",
(payload: { message?: string }) => {
connectionError = payload?.message || "Live quote preview error";
},
}
);
}
async function attemptTokenRefreshAndReconnect() {
if (isRefreshingToken) return;
isRefreshingToken = true;
connectionError = "Session expired — refreshing…";
try {
const res = await fetch("/api/auth/refresh");
if (res.ok) {
const body = (await res.json()) as { accessToken?: string };
if (body.accessToken) {
socketAccessToken = body.accessToken;
teardownSocket();
setupSocket();
return;
}
}
// Refresh failed — kick to login
window.location.assign("/logout");
} catch {
window.location.assign("/logout");
} finally {
isRefreshingToken = false;
}
}
onMount(() => {
// Lazy-load detailed quote data (regen data & params) now that the tab is active
loadQuoteDetails();
loadQuoteNarrative();
// Set up live preview socket (only if user has preview permission)
if (!accessToken || !opportunityId || !canPreviewQuote) return;
socketAccessToken = accessToken;
setupSocket();
// Load preview for initially selected quote
if (selectedQuote) {
@@ -596,6 +729,40 @@
};
});
async function handleCommitQuote() {
if (!accessToken || !opportunityId || isCommitting) return;
isCommitting = true;
commitError = "";
commitSuccess = "";
try {
const result = await sales.commitQuote(accessToken, opportunityId, {
lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives,
});
commitSuccess = result.message || "Quote created successfully!";
// Reload quotes (basic + detail) and switch to list view
detailLoaded = false;
await loadQuotes();
await loadQuoteDetails();
if (result.data) {
selectedQuote =
quotes.find((q) => q.id === result.data.id) ?? quotes[0] ?? null;
}
viewMode = "list";
if (selectedQuote) {
loadQuotePreview(selectedQuote.id);
}
setTimeout(() => (commitSuccess = ""), 5000);
} catch (err: unknown) {
commitError =
err instanceof Error ? err.message : "Failed to create quote";
setTimeout(() => (commitError = ""), 5000);
} finally {
isCommitting = false;
}
}
onDestroy(() => {
teardownSocket();
cleanupObjectUrl();
@@ -605,6 +772,10 @@
$: if (isConnected && liveEventName) {
publishPreviewPayload();
}
$: if (viewMode === "narrative") {
loadQuoteNarrative();
}
</script>
<div class="quotes-tab">
@@ -656,6 +827,28 @@
</svg>
Live Preview
</button>
<button
class="quotes-view-tab"
class:active={viewMode === "narrative"}
type="button"
on:click={switchToNarrative}
disabled={!canEditQuoteNarrative}
title={canEditQuoteNarrative
? "Edit the quote narrative stored in CW"
: "You do not have permission to edit quote narrative"}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M4 5h16M4 12h16M4 19h10" />
</svg>
Narrative
</button>
{#if canFetchDownloads}
<button
class="quotes-view-tab"
@@ -1035,7 +1228,111 @@
</div>
</div>
<!-- ═══ LIVE PREVIEW VIEW ═══ -->
<!-- ═══ NARRATIVE VIEW ═══ -->
{:else if viewMode === "narrative"}
<div class="quotes-preview-layout">
<div class="quotes-preview-sidebar">
<div class="quotes-sidebar-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M4 5h16M4 12h16M4 19h10" />
</svg>
<span>Quote Narrative</span>
</div>
<div class="quotes-sidebar-divider"></div>
<div class="quotes-preview-options">
<div class="quotes-narrative-header">
<span class="quotes-field-label">Edit narrative</span>
<div class="quotes-narrative-unsaved-indicator">
{#if hasNarrativeChanges}
<span class="unsaved-badge">Unsaved changes</span>
{/if}
</div>
</div>
{#if quoteNarrativeLoading}
<div class="quotes-list-empty">Loading narrative...</div>
{:else}
<div class="quotes-narrative-editor-container">
<textarea
class="quotes-narrative-textarea"
bind:value={quoteNarrative}
on:input={scheduleAutoSave}
on:keydown={handleNarrativeKeydown}
placeholder="Enter the quote narrative that will appear in generated quotes. This text is stored in CW opportunity custom field 35. You can use markdown-style formatting..."
rows="14"
disabled={!canEditQuoteNarrative}
></textarea>
<div class="quotes-narrative-stats">
<span class="quotes-stat">{narrativeStats.words} words</span>
<span class="quotes-stat"
>{narrativeStats.chars} characters</span
>
</div>
</div>
{/if}
</div>
<div class="quotes-narrative-actions">
<button
class="quotes-create-btn"
type="button"
disabled={!hasNarrativeChanges ||
!accessToken ||
!opportunityId ||
quoteNarrativeSaving ||
!canEditQuoteNarrative}
on:click={() => saveQuoteNarrative()}
>
{#if quoteNarrativeSaving}
<span class="saving-indicator">⌛ Saving...</span>
{:else if hasNarrativeChanges}
<span class="save-indicator">↓ Save Narrative</span>
{:else}
<span class="saved-indicator">✓ Saved</span>
{/if}
</button>
{#if quoteNarrativeError}
<div class="quotes-commit-msg quotes-commit-error">
{quoteNarrativeError}
</div>
{/if}
{#if quoteNarrativeSuccess}
<div class="quotes-commit-msg quotes-commit-success">
{quoteNarrativeSuccess}
</div>
{/if}
</div>
</div>
<div class="quotes-detail-preview">
<div class="quotes-detail-preview-frame">
<div class="quotes-pdf-page quotes-pdf-page-mini">
<div class="quotes-narrative-preview-header">
<h4>Preview as it will appear in quotes</h4>
</div>
<div class="quotes-narrative-preview">
{#if quoteNarrative.trim()}
<div class="preview-content">
{quoteNarrative}
</div>
{:else}
<div class="preview-empty">No narrative entered yet</div>
{/if}
</div>
</div>
</div>
</div>
</div>
{:else if viewMode === "preview"}
<div class="quotes-preview-layout">
<div class="quotes-preview-sidebar">
@@ -1307,3 +1604,135 @@
</div>
{/if}
</div>
<style>
.quotes-narrative-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.quotes-narrative-unsaved-indicator {
display: flex;
align-items: center;
}
.unsaved-badge {
font-size: 0.75rem;
background-color: var(--status-pending-bg);
color: var(--status-pending-color);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.quotes-narrative-editor-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.quotes-narrative-textarea {
padding: 0.75rem;
border: 1px solid var(--input-border);
border-radius: 0.375rem;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
transition: all 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
.quotes-narrative-textarea:focus {
outline: none;
border-color: var(--input-focus-border);
background-color: var(--input-focus-bg);
box-shadow: 0 0 0 3px var(--input-focus-ring);
}
.quotes-narrative-textarea:disabled {
background-color: var(--bg-inset);
color: var(--text-muted);
cursor: not-allowed;
}
.quotes-narrative-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
padding: 0 0.25rem;
}
.quotes-stat {
display: flex;
align-items: center;
}
.quotes-narrative-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.saving-indicator,
.save-indicator,
.saved-indicator {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.quotes-narrative-preview-header {
margin-bottom: 1rem;
}
.quotes-narrative-preview-header h4 {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 500;
}
.quotes-narrative-preview {
font-size: 0.875rem;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
padding: 1rem;
background-color: var(--input-bg);
border-radius: 0.375rem;
min-height: 200px;
max-height: 500px;
overflow-y: auto;
}
.preview-content {
font-family: inherit;
}
.preview-empty {
color: var(--text-muted);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
@@ -26,7 +26,9 @@
import ReviewDecisionModal from "./ReviewDecisionModal.svelte";
import FinalizeModal from "./FinalizeModal.svelte";
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher<{
workflowChanged: { result: WorkflowResult; freshWorkflowStatus: WorkflowStatusResponse | null };
}>();
export let opportunity: SalesOpportunity | null;
export let workflowStatus: WorkflowStatusResponse | null;
@@ -247,13 +249,41 @@
return null;
}
// Success — close modal, merge new activities for immediate gating update, and refresh
// Success — close modal, merge new activities for immediate gating update
closeModal();
const result = json.data as WorkflowResult;
if (result.activitiesCreated?.length) {
activities = [...result.activitiesCreated, ...activities];
}
dispatch("workflowChanged", result);
// Fetch fresh workflow status immediately so the parent can update without a full reload
let freshWorkflowStatus: WorkflowStatusResponse | null = null;
const expectedStatusId = result.newStatusId;
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, 400 * attempt));
}
const statusJson = await clientFetch<{ data: WorkflowStatusResponse }>(
`/sales/opportunity/${opportunityId}/workflow`,
);
freshWorkflowStatus = statusJson.data ?? null;
// If the fresh status matches the expected new state, we're done
if (
!expectedStatusId ||
freshWorkflowStatus?.currentStatusId === expectedStatusId
) {
break;
}
// Stale data — retry
freshWorkflowStatus = null;
} catch {
// Fetch failed — retry
}
}
dispatch("workflowChanged", { result, freshWorkflowStatus });
return result;
} catch (err) {
workflowError =
+11 -4
View File
@@ -1,4 +1,4 @@
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type { SalesOpportunity, OpportunityType } from "$lib/optima-api/modules/sales";
import type {
CommittedQuote,
WorkflowStatusResponse,
@@ -21,6 +21,7 @@ export {
TERMINAL_STATUSES,
REOPENABLE_STATUSES,
};
export type { OpportunityType };
export type {
WorkflowStatusResponse,
WorkflowHistoryResponse,
@@ -120,6 +121,9 @@ export interface OpportunityProduct {
productNarrative?: string | null;
customerDescription?: string | null;
procurementNotes?: string | null;
unitPrice?: number;
listPrice?: number;
discount?: number;
[key: string]: unknown;
}
@@ -143,6 +147,7 @@ export interface PageData {
accessToken: string | null;
permissions: PermissionMap;
workflowStatus: WorkflowStatusResponse | null;
opportunityTypes: OpportunityType[];
}
export function opportunityInitials(name: string): string {
@@ -157,9 +162,11 @@ export function opportunityInitials(name: string): string {
export function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
// Append T00:00:00 to date-only strings so they parse as local midnight
const normalized = dateStr.includes("T") ? dateStr : dateStr + "T00:00:00";
return new Date(normalized).toLocaleDateString("en-US", {
// Strip time portion for date-only fields to prevent timezone shift
const dateOnly = dateStr.split("T")[0];
const date = new Date(dateOnly + "T00:00:00");
if (isNaN(date.getTime())) return "—";
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",