all the haul
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user