feat: add workflow actions, admin enhancements, and comprehensive test coverage

This commit is contained in:
2026-03-09 02:14:08 -05:00
parent 5169107a04
commit 7073f5aa33
71 changed files with 12902 additions and 541 deletions
+106
View File
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted(
() => ({
mockOptima: {
sales: {
fetchMany: vi.fn(),
fetchOpportunityTypes: vi.fn(),
},
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
}),
);
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
import { load } from "./+page.server";
describe("sales +page.server.ts load", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty data when no token", async () => {
const result = await load({
locals: {},
url: new URL("http://localhost/sales"),
} as any);
expect(result).toMatchObject({
opportunities: [],
opportunityTypes: [],
totalPages: 1,
currentPage: 1,
totalRecords: 0,
search: "",
includeClosed: true,
});
});
it("fetches opportunities with pagination", async () => {
mockOptima.sales.fetchMany.mockResolvedValueOnce({
data: [{ id: "opp-1" }],
meta: {
pagination: { totalPages: 3, currentPage: 2, totalRecords: 90 },
},
});
mockCheckPermissions.mockResolvedValueOnce({
"sales.opportunity.fetch.many": true,
});
mockOptima.sales.fetchOpportunityTypes.mockResolvedValueOnce({
data: ["Type A"],
});
const result = await load({
locals: { session: { accessToken: "tok" } },
url: new URL("http://localhost/sales?page=2&search=deal"),
} as any);
expect(mockOptima.sales.fetchMany).toHaveBeenCalledWith(
"tok",
2,
"deal",
30,
true,
);
expect(result).toMatchObject({
totalPages: 3,
currentPage: 2,
search: "deal",
});
});
it("passes includeClosed=false when param is false", async () => {
mockOptima.sales.fetchMany.mockResolvedValueOnce({
data: [],
meta: {
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
},
});
mockCheckPermissions.mockResolvedValueOnce({});
mockOptima.sales.fetchOpportunityTypes.mockResolvedValueOnce({
data: [],
});
await load({
locals: { session: { accessToken: "tok" } },
url: new URL("http://localhost/sales?includeClosed=false"),
} as any);
expect(mockOptima.sales.fetchMany).toHaveBeenCalledWith(
"tok",
1,
"",
30,
false,
);
});
});
@@ -253,6 +253,7 @@
if (t.closedFlag) return "status-closed";
if (t.inactiveFlag) return "status-inactive";
const n = t.name.toLowerCase();
if (n.includes("cancel")) return "status-canceled";
if (n.includes("future")) return "status-future";
if (n.includes("new")) return "status-new";
if (n.includes("review")) return "status-review";
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted(
() => ({
mockOptima: {
sales: {
fetchOne: vi.fn(),
fetchWorkflowStatus: vi.fn(),
},
},
mockCheckPermissions: vi.fn(),
mockHandleApiError: vi.fn(),
}),
);
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("$lib/permissions", () => ({
checkPermissions: mockCheckPermissions,
}));
vi.mock("$lib/optima-api/errorHandler", () => ({
handleApiError: mockHandleApiError,
}));
// Mock fs/path so we don't write debug files
vi.mock("fs", () => ({
writeFileSync: vi.fn(),
}));
vi.mock("path", () => ({
resolve: vi.fn((...args: string[]) => args.join("/")),
}));
import { load } from "./+page.server";
describe("sales/opportunity/[id] +page.server.ts load", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("returns empty data when no token", async () => {
const result = await load({
locals: {},
params: { id: "opp-1" },
} as any);
expect(result).toMatchObject({
opportunity: null,
notes: [],
contacts: [],
products: [],
quotes: [],
accessToken: null,
workflowStatus: null,
});
});
it("loads opportunity with all includes", async () => {
mockOptima.sales.fetchOne.mockResolvedValueOnce({
data: {
id: "opp-1",
name: "Big Deal",
notes: [{ id: 1 }],
contacts: [{ id: "c1" }],
products: [{ id: "p1" }],
quotes: [{ id: "q1" }],
},
});
mockCheckPermissions.mockResolvedValueOnce({
"sales.opportunity.fetch": true,
});
mockOptima.sales.fetchWorkflowStatus.mockResolvedValueOnce({
data: { state: "draft" },
});
const result = await load({
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
} as any);
expect(mockOptima.sales.fetchOne).toHaveBeenCalledWith("tok", "opp-1", [
"notes",
"contacts",
"products",
"quotes",
]);
expect(result).toMatchObject({
opportunity: expect.objectContaining({ id: "opp-1" }),
notes: [{ id: 1 }],
contacts: [{ id: "c1" }],
products: [{ id: "p1" }],
quotes: [{ id: "q1" }],
accessToken: "tok",
workflowStatus: { state: "draft" },
});
});
it("handles workflow status fetch failure gracefully", async () => {
mockOptima.sales.fetchOne.mockResolvedValueOnce({
data: { id: "opp-1" },
});
mockCheckPermissions.mockResolvedValueOnce({});
mockOptima.sales.fetchWorkflowStatus.mockRejectedValueOnce(
new Error("fail"),
);
const result = await load({
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
} as any);
expect(result).toMatchObject({
workflowStatus: null,
});
});
});
@@ -14,11 +14,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
quotes: [],
accessToken: null,
permissions: {} as PermissionMap,
workflowStatus: null,
};
}
try {
const [result, permissions] = await Promise.all([
const [result, permissions, workflowResult] = await Promise.all([
optima.sales.fetchOne(accessToken, params.id, [
"notes",
"contacts",
@@ -38,8 +39,25 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.quote.fetch_downloads",
"sales.opportunity.view_margin",
"sales.opportunity.view_cost",
"sales.opportunity.view_profit",
"sales.opportunity.update",
"sales.opportunity.delete",
"sales.opportunity.product.delete",
"sales.opportunity.product.update",
"sales.opportunity.workflow",
"sales.opportunity.finalize",
"sales.opportunity.cancel",
"sales.opportunity.review",
"sales.opportunity.send",
"sales.opportunity.reopen",
"sales.opportunity.win",
"sales.opportunity.lose",
"ui.navigation.reports.view",
]),
optima.sales.fetchWorkflowStatus(accessToken, params.id).catch((err) => {
console.error("[Workflow] Failed to load workflow status:", err);
return null;
}),
]);
const { writeFileSync } = await import("fs");
@@ -54,6 +72,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
const contacts = result?.data?.contacts ?? [];
const products = result?.data?.products ?? [];
const quotes = result?.data?.quotes ?? [];
const workflowStatus = workflowResult?.data ?? null;
return {
opportunity,
@@ -64,6 +83,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
quotes,
accessToken,
permissions,
workflowStatus,
};
} catch (err) {
handleApiError(err);
+73 -32
View File
@@ -8,10 +8,11 @@
import OpportunitySidebar from "./components/OpportunitySidebar.svelte";
import OverviewTab from "./components/OverviewTab.svelte";
import NotesTab from "./components/NotesTab.svelte";
import ContactsTab from "./components/ContactsTab.svelte";
import ActivityTab from "./components/ActivityTab.svelte";
import ProductsTab from "./components/ProductsTab.svelte";
import QuotesTab from "./components/QuotesTab.svelte";
import OpportunityReportsTab from "./components/OpportunityReportsTab.svelte";
import WorkflowPanel from "./components/WorkflowPanel.svelte";
export let data: PageData;
@@ -22,6 +23,21 @@
$: products = data.products;
$: quotes = data.quotes ?? [];
$: permissions = data.permissions;
$: workflowStatus = data.workflowStatus ?? null;
// Closed opportunity lockdown no edits except admin delete
$: isClosedOpportunity = (() => {
if (!opportunity) return false;
const statusText =
`${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase();
return (
!!opportunity.closedFlag ||
!!opportunity.closedDate ||
statusText.includes("won") ||
statusText.includes("lost")
);
})();
let localProductSequence: number[] | null =
data.opportunity?.productSequence ?? null;
@@ -52,17 +68,24 @@
"Products",
"Quotes",
"Notes",
"Contacts",
"Activity",
"Reports",
] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Overview";
// Hide Quotes tab if user lacks fetch permission
$: visibleTabs = tabs.filter(
(t) =>
t !== "Quotes" || permissions["sales.opportunity.quote.fetch"] !== false,
);
// Hide Reports tab if user lacks reports permission
$: visibleTabs = tabs.filter((t) => {
if (
t === "Quotes" &&
permissions["sales.opportunity.quote.fetch"] === false
)
return false;
if (t === "Reports" && permissions["ui.navigation.reports.view"] === false)
return false;
return true;
});
// Track whether ProductsTab is in edit mode
let productsEditing = false;
@@ -84,11 +107,19 @@
// Product to auto-select when switching to Products tab
let pendingProductId: number | null = null;
// Quote to auto-select when switching to Quotes tab
let pendingQuoteId: string | null = null;
function handleSelectProduct(e: CustomEvent<number>) {
pendingProductId = e.detail;
guardedSetTab("Products");
}
function handleViewQuote(e: CustomEvent<string>) {
pendingQuoteId = e.detail;
guardedSetTab("Quotes");
}
function handleSequenceSaved(e: CustomEvent<number[]>) {
localProductSequence = e.detail;
}
@@ -97,6 +128,12 @@
products = e.detail;
}
function handleQuotesChanged(
e: CustomEvent<import("$lib/optima-api/modules/sales").CommittedQuote[]>,
) {
quotes = e.detail;
}
// Mobile nav state
let mobileActiveTab: Tab | null = null;
@@ -135,6 +172,8 @@
{isMobile}
{mobileActiveTab}
{permissions}
{isClosedOpportunity}
{workflowStatus}
accessToken={data.accessToken}
on:updated={() => invalidateAll()}
/>
@@ -182,23 +221,6 @@
y2="13"
/><line x1="16" y1="17" x2="8" y2="17" />
</svg>
{:else if tab === "Contacts"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
d="M16 3.13a4 4 0 010 7.75"
/>
</svg>
{:else if tab === "Quotes"}
<svg
viewBox="0 0 24 24"
@@ -235,9 +257,6 @@
{#if tab === "Notes" && notes.length > 0}
<span class="mobile-nav-badge">{notes.length}</span>
{/if}
{#if tab === "Contacts" && contacts.length > 0}
<span class="mobile-nav-badge">{contacts.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="mobile-nav-badge">{quotes.length}</span>
{/if}
@@ -302,14 +321,25 @@
{#if tab === "Notes" && notes.length > 0}
<span class="tab-count-badge">{notes.length}</span>
{/if}
{#if tab === "Contacts" && contacts.length > 0}
<span class="tab-count-badge">{contacts.length}</span>
{/if}
{#if tab === "Quotes" && quotes.length > 0}
<span class="tab-count-badge">{quotes.length}</span>
{/if}
</button>
{/each}
<!-- Workflow actions pushed to right side of tab bar -->
{#if opportunity && opportunityId}
<WorkflowPanel
{opportunity}
{workflowStatus}
{permissions}
{opportunityId}
{quotes}
activities={opportunity?.activities ?? []}
inline={true}
on:workflowChanged={() => invalidateAll()}
/>
{/if}
</div>
<div class="detail-pane-body">
{#if activeTab === "Overview"}
@@ -318,7 +348,9 @@
{notes}
{contacts}
{products}
{permissions}
on:selectProduct={handleSelectProduct}
on:switchTab={(e) => guardedSetTab(e.detail as Tab)}
/>
{:else if activeTab === "Products"}
<ProductsTab
@@ -328,6 +360,7 @@
productSequence={localProductSequence}
initialProductId={pendingProductId}
{permissions}
{isClosedOpportunity}
bind:isEditing={productsEditing}
on:sequenceSaved={handleSequenceSaved}
on:productsChanged={handleProductsChanged}
@@ -337,21 +370,29 @@
accessToken={data.accessToken}
opportunityId={data.opportunityId}
initialQuotes={quotes}
initialQuoteId={pendingQuoteId}
{permissions}
{isClosedOpportunity}
on:quotesChanged={handleQuotesChanged}
/>
{:else if activeTab === "Notes"}
<NotesTab
{notes}
{permissions}
{opportunityId}
{isClosedOpportunity}
on:notesChanged={() => {
invalidateAll();
}}
/>
{:else if activeTab === "Contacts"}
<ContactsTab {contacts} />
{:else if activeTab === "Activity"}
<ActivityTab />
<ActivityTab
{opportunityId}
activities={opportunity?.activities ?? []}
on:viewQuote={handleViewQuote}
/>
{:else if activeTab === "Reports"}
<OpportunityReportsTab {opportunity} {workflowStatus} {opportunityId} />
{/if}
</div>
</div>
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
mockOptima: {
sales: { deleteOpportunity: vi.fn() },
},
mockJson: vi.fn((data, init?) => {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
});
}),
mockError: vi.fn((status: number, message: string) => {
throw { status, body: { message } };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
import { DELETE } from "./+server";
describe("DELETE /sales/opportunity/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("throws 401 when no access token", async () => {
const event = { locals: {}, params: { id: "opp-1" } };
await expect(DELETE(event as any)).rejects.toEqual(
expect.objectContaining({ status: 401 }),
);
});
it("deletes opportunity successfully", async () => {
mockOptima.sales.deleteOpportunity.mockResolvedValueOnce({ success: true });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
};
await DELETE(event as any);
expect(mockOptima.sales.deleteOpportunity).toHaveBeenCalledWith(
"tok",
"opp-1",
);
expect(mockJson).toHaveBeenCalledWith({ success: true });
});
it("throws error on failure", async () => {
mockOptima.sales.deleteOpportunity.mockRejectedValueOnce({
status: 404,
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
};
await expect(DELETE(event as any)).rejects.toBeDefined();
});
});
@@ -0,0 +1,21 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** DELETE /sales/opportunity/[id] — delete an opportunity */
export const DELETE: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
try {
const result = await optima.sales.deleteOpportunity(accessToken, params.id);
return json(result);
} catch (err: unknown) {
console.error("Failed to delete opportunity:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
throw error(status, "Failed to delete opportunity");
}
};
@@ -1,8 +1,186 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import type {
WorkflowHistoryEntry,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import { formatDateTime } from "../types";
const dispatch = createEventDispatcher<{ viewQuote: string }>();
export let opportunityId: string;
export let activities: OpportunityActivity[] = [];
let workflowHistory: WorkflowHistoryEntry[] = [];
let isLoading = true;
let loadError: string | null = null;
onMount(() => {
fetchHistory();
});
async function fetchHistory() {
isLoading = true;
loadError = null;
try {
const res = await fetch(
`/sales/opportunity/${opportunityId}/workflow/history`,
);
const json = await res.json();
if (res.ok && json.data) {
workflowHistory = json.data.activities ?? [];
} else {
loadError = json.message ?? "Failed to load workflow history";
}
} catch (err) {
loadError =
err instanceof Error ? err.message : "Failed to load workflow history";
} finally {
isLoading = false;
}
}
export function refresh() {
fetchHistory();
}
/** Extract the Optima_Type from custom fields */
function getOptimaType(activity: OpportunityActivity): string | null {
const cf = activity.customFields?.find(
(f) => f.id === 45 || f.caption === "Optima_Type",
);
return cf?.value ?? null;
}
/** Get an icon for the Optima_Type */
function optimaTypeIcon(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-7zM9 12l2 2 4-4";
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";
}
}
/** CSS class for Optima_Type badge */
function optimaTypeBadgeClass(type: string | null): string {
switch (type) {
case "Opportunity Created":
return "at-type-created";
case "Opportunity Setup":
return "at-type-setup";
case "Opportunity Review":
return "at-type-review";
case "Quote Sent":
return "at-type-sent";
case "Quote Confirmed":
return "at-type-confirmed";
case "Quote Sent & Confirmed":
return "at-type-sent-confirmed";
case "Revision":
return "at-type-revision";
case "Finalized":
return "at-type-finalized";
case "Converted":
return "at-type-converted";
case "Quote Generated":
return "at-type-generated";
default:
return "at-type-default";
}
}
/** Determine if an activity is system-generated (no assignTo or automation-generated) */
function isSystemActivity(activity: OpportunityActivity): boolean {
return !activity.assignTo?.name && !activity.cwEnteredBy;
}
/** Format the assigned user display */
function assignedDisplay(activity: OpportunityActivity): string {
if (isSystemActivity(activity)) return "System";
return activity.assignTo?.name ?? activity.cwEnteredBy ?? "Unknown";
}
/** Check if activity is a status transition (has CW status change in notes) */
function parseStatusTransition(
notes: string | undefined,
): { from: string; to: string } | null {
if (!notes) return null;
// The workflow engine embeds status transitions in notes like: [StatusA → StatusB]
const match = notes.match(/\[(\w+)\s*→\s*(\w+)\]/);
if (match) return { from: match[1], to: match[2] };
return null;
}
/** Check if activity is open (closed flag first, then status id, then dateEnd fallback) */
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;
}
/** Check if activity is late (dateEnd > dueDate-equivalent) */
function isLateActivity(_activity: OpportunityActivity): {
late: boolean;
days: number;
} {
// Activities use dateStart/dateEnd, not dueDate. Check if closed after scheduled end.
return { late: false, days: 0 };
}
/** Combine CW activities with workflow history into a unified timeline, sorted by creation date */
$: timelineItems = (() => {
let items;
// If we have workflow history, use that (it's already filtered + enriched)
if (workflowHistory.length > 0) {
items = workflowHistory.map((h) => ({
activity: h.activity,
optimaType: h.optimaType,
quoteId: h.quoteId ?? null,
closed: h.closed ?? null,
closedAt: h.closedAt ?? null,
isWorkflow: true,
}));
} else {
// Fall back to raw activities
items = (activities ?? []).map((a) => ({
activity: a,
optimaType: getOptimaType(a),
quoteId: null as string | null,
closed: null as boolean | null,
closedAt: null as string | null,
isWorkflow: false,
}));
}
// Sort by creation date (cwDateEntered or dateStart), newest first
return items.sort((a, b) => {
const dateA = a.activity.cwDateEntered ?? a.activity.dateStart ?? "";
const dateB = b.activity.cwDateEntered ?? b.activity.dateStart ?? "";
return dateB.localeCompare(dateA);
});
})();
</script>
<div class="activity-tab">
<div class="overview-section">
<div class="at-header">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
@@ -14,8 +192,257 @@
>
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
</svg>
Recent Activity
Workflow Activity
</h3>
<p class="overview-placeholder">Activity feed coming soon.</p>
<button
class="at-refresh-btn"
on:click={fetchHistory}
disabled={isLoading}
title="Refresh"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
class:at-spinning={isLoading}
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"
/>
</svg>
</button>
</div>
{#if isLoading && timelineItems.length === 0}
<div class="at-loading">
<span class="wf-spinner"></span>
<span>Loading activity history...</span>
</div>
{:else if loadError}
<div class="at-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{loadError}</span>
</div>
{:else if timelineItems.length === 0}
<div class="at-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
</svg>
<p>No workflow activity yet.</p>
</div>
{:else}
<div class="at-timeline">
{#each timelineItems as item, i (item.activity.cwActivityId ?? i)}
{@const act = item.activity}
{@const transition = parseStatusTransition(act.notes)}
{@const open = item.closed != null ? !item.closed : isOpenActivity(act)}
<div class="at-entry" class:at-entry-open={open}>
<!-- Timeline connector -->
<div class="at-connector">
<div
class="at-dot"
class:at-dot-open={open}
class:at-dot-system={isSystemActivity(act)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<path d={optimaTypeIcon(item.optimaType)} />
</svg>
</div>
{#if i < timelineItems.length - 1}
<div class="at-line"></div>
{/if}
</div>
<!-- Entry content -->
<div class="at-content">
<div class="at-content-header">
<!-- Optima type badge -->
{#if item.optimaType}
<span
class="at-type-badge {optimaTypeBadgeClass(item.optimaType)}"
>
{item.optimaType}
</span>
{/if}
<!-- Status transition pill -->
{#if transition}
<span class="at-transition-pill">
<span class="at-transition-from">{transition.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">{transition.to}</span>
</span>
{/if}
<!-- Open indicator -->
{#if open}
<span class="at-open-badge">Open</span>
{/if}
</div>
<!-- Activity name -->
{#if act.name}
<p class="at-name">{act.name}</p>
{/if}
<!-- Notes body -->
{#if act.notes}
<p class="at-notes">{act.notes}</p>
{/if}
<!-- Meta row -->
<div class="at-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="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(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}
{#if item.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(item.closedAt)}
</span>
{:else if act.closedAt}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.closedAt)}
</span>
{:else if act.dateEnd}
<span class="at-timestamp at-closed-at">
Closed: {formatDateTime(act.dateEnd)}
</span>
{/if}
</div>
<!-- Quote reference sub-item -->
{#if item.quoteId && item.optimaType === "Quote Generated"}
<button
class="at-quote-link"
on:click={() =>
item.quoteId && dispatch("viewQuote", item.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">{item.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>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,394 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowActionPayload,
WorkflowStatusKey,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let statusKey: WorkflowStatusKey | null = null;
export let hasWon = false;
export let hasLost = false;
export let canFinalize = false;
export let initialOutcome: "won" | "lost" | null = null;
export let error: string | null = null;
export let isSubmitting = false;
// If user has finalize permission, show checkbox (checked by default)
// If not, the action will send to Pending Won/Lost instead
let finalizeImmediately = canFinalize;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let noteError = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
let selectedOutcome: "won" | "lost" | null = initialOutcome;
// Auto-select based on initialOutcome, current pending status, or single-outcome availability
$: if (initialOutcome && selectedOutcome === null) {
selectedOutcome = initialOutcome;
} else if (statusKey === "PendingWon" && hasWon && selectedOutcome === null) {
selectedOutcome = "won";
} else if (
statusKey === "PendingLost" &&
hasLost &&
selectedOutcome === null
) {
selectedOutcome = "lost";
} else if (hasWon && !hasLost && selectedOutcome === null) {
selectedOutcome = "won";
} else if (hasLost && !hasWon && selectedOutcome === null) {
selectedOutcome = "lost";
}
function validate(): boolean {
noteError = "";
if (!note.trim()) {
noteError = "A note is required to finalize.";
return false;
}
if (!selectedOutcome) {
return false;
}
return true;
}
function handleSubmit() {
if (!validate()) return;
const payload: WorkflowActionPayload = {
outcome: selectedOutcome!,
finalize: canFinalize && finalizeImmediately,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
noteError = "";
selectedOutcome = null;
finalizeImmediately = canFinalize;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
$: pendingOnly = !canFinalize || !finalizeImmediately;
$: title = pendingOnly
? selectedOutcome === "won"
? "Mark as Pending Won"
: selectedOutcome === "lost"
? "Mark as Pending Lost"
: "Mark Outcome"
: selectedOutcome === "won"
? "Finalize as Won"
: selectedOutcome === "lost"
? "Finalize as Lost"
: "Finalize Opportunity";
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal" on:click|stopPropagation>
<div
class="wf-modal-header"
class:wf-header-won={selectedOutcome === "won"}
class:wf-header-lost={selectedOutcome === "lost"}
>
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Outcome selector (if both are available) -->
{#if hasWon && hasLost}
<div
class="wf-finalize-selector"
class:has-selection={selectedOutcome !== null}
class:lost-selected={selectedOutcome === "lost"}
>
<button
class="wf-finalize-option wf-finalize-won"
class:wf-selected={selectedOutcome === "won"}
on:click={() => (selectedOutcome = "won")}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline
points="22 4 12 14.01 9 11.01"
/>
</svg>
<span>Won</span>
</button>
<button
class="wf-finalize-option wf-finalize-lost"
class:wf-selected={selectedOutcome === "lost"}
on:click={() => (selectedOutcome = "lost")}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>Lost</span>
</button>
</div>
{/if}
<!-- Finalize checkbox (only if user has finalize permission) -->
{#if canFinalize}
<label class="wf-finalize-check">
<input type="checkbox" bind:checked={finalizeImmediately} />
<span>Finalize immediately</span>
<span
class="wf-info-icon"
title="Skip the pending approval step and finalize directly to Won or Lost."
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="16"
x2="12"
y2="12"
/><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
</label>
{/if}
<!-- Pending disclaimer -->
{#if pendingOnly}
<div class="wf-pending-notice">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="12"
y1="16"
x2="12"
y2="12"
/><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span
>This will set the opportunity to <strong
>Pending {selectedOutcome === "won"
? "Won"
: selectedOutcome === "lost"
? "Lost"
: "Won/Lost"}</strong
> for manager reviewal before finalizing.</span
>
</div>
{/if}
<!-- Note field (required) -->
<div class="wf-field-group">
<label class="wf-field-label" for="fin-note">
Note <span class="wf-required">*</span>
</label>
<textarea
id="fin-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder="Explain the finalization..."
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="fin-time-start">Start</label>
<input
id="fin-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="fin-time-end">End</label>
<input
id="fin-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
</div>
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn {selectedOutcome === 'won'
? 'wf-btn-success'
: 'wf-btn-danger'}"
on:click={handleSubmit}
disabled={isSubmitting || !selectedOutcome}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -8,12 +8,19 @@
export let notes: OpportunityNote[] = [];
export let permissions: PermissionMap = {} as PermissionMap;
export let opportunityId: string;
export let isClosedOpportunity: boolean = false;
const dispatch = createEventDispatcher();
$: canCreate = permissions["sales.opportunity.note.create"] === true;
$: canUpdate = permissions["sales.opportunity.note.update"] === true;
$: canDelete = permissions["sales.opportunity.note.delete"] === true;
$: canCreate =
!isClosedOpportunity &&
permissions["sales.opportunity.note.create"] === true;
$: canUpdate =
!isClosedOpportunity &&
permissions["sales.opportunity.note.update"] === true;
$: canDelete =
!isClosedOpportunity &&
permissions["sales.opportunity.note.delete"] === true;
// ── Compose state ──
let composing = false;
@@ -0,0 +1,335 @@
<script lang="ts">
import type { WorkflowStatusResponse } from "../types";
import {
WORKFLOW_STATUS_LABELS,
STATUS_ID_TO_KEY,
} from "$lib/optima-api/modules/sales";
import type {
SalesOpportunity,
WorkflowHistoryEntry,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import { formatDateTime } from "../types";
export let opportunity: SalesOpportunity | null;
export let workflowStatus: WorkflowStatusResponse | null;
export let opportunityId: string;
let historyEntries: WorkflowHistoryEntry[] = [];
let isLoading = true;
let loadError: string | null = null;
// Fetch workflow history on mount
import { onMount } from "svelte";
onMount(async () => {
await fetchHistory();
});
async function fetchHistory() {
isLoading = true;
loadError = null;
try {
const res = await fetch(
`/sales/opportunity/${opportunityId}/workflow/history`,
);
const json = await res.json();
if (res.ok && json.data) {
historyEntries = json.data.activities ?? [];
} else {
loadError = json.message ?? "Failed to load history";
}
} catch (err) {
loadError = err instanceof Error ? err.message : "Failed to load history";
} finally {
isLoading = false;
}
}
// Derive stats from history
$: totalActions = historyEntries.length;
$: uniqueTypes = (() => {
const types = new Set<string>();
for (const e of historyEntries) {
if (e.optimaType) types.add(e.optimaType);
}
return types;
})();
$: currentStatusLabel = (() => {
if (!workflowStatus) return opportunity?.status?.name ?? "Unknown";
const key = STATUS_ID_TO_KEY[workflowStatus.currentStatusId];
return key ? WORKFLOW_STATUS_LABELS[key] : workflowStatus.currentStatus;
})();
$: isCold = workflowStatus?.coldCheck?.isCold ?? false;
$: staleDays = workflowStatus?.coldCheck?.daysSinceActivity ?? null;
</script>
<div class="opp-report-tab">
<div class="opp-report-header">
<h3 class="overview-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Opportunity Report
</h3>
</div>
<!-- Summary Cards -->
<div class="opp-report-cards">
<div class="opp-report-card">
<span class="opp-report-card-label">Current Status</span>
<span class="opp-report-card-value">{currentStatusLabel}</span>
</div>
<div class="opp-report-card">
<span class="opp-report-card-label">Workflow Actions</span>
<span class="opp-report-card-value">{totalActions}</span>
</div>
<div class="opp-report-card">
<span class="opp-report-card-label">Activity Types</span>
<span class="opp-report-card-value">{uniqueTypes.size}</span>
</div>
{#if staleDays !== null}
<div class="opp-report-card" class:opp-report-card-warning={isCold}>
<span class="opp-report-card-label">Days Since Activity</span>
<span class="opp-report-card-value">{staleDays}</span>
</div>
{/if}
</div>
<!-- History Table -->
<div class="opp-report-section">
<h4 class="opp-report-section-title">Workflow History</h4>
{#if isLoading}
<div class="opp-report-loading">
<span class="wf-spinner"></span>
Loading history...
</div>
{:else if loadError}
<div class="opp-report-error">{loadError}</div>
{:else if historyEntries.length === 0}
<p class="opp-report-empty">No workflow history for this opportunity.</p>
{:else}
<div class="opp-report-table-wrap">
<table class="opp-report-table">
<thead>
<tr>
<th>Type</th>
<th>Activity</th>
<th>Assigned To</th>
<th>Started</th>
<th>Closed</th>
</tr>
</thead>
<tbody>
{#each historyEntries as entry, i (entry.activity.cwActivityId ?? i)}
{@const act = entry.activity}
<tr>
<td>
{#if entry.optimaType}
<span class="opp-report-type-badge">{entry.optimaType}</span
>
{:else}
<span class="opp-report-type-muted"></span>
{/if}
</td>
<td>
{#if act.name}
<span class="opp-report-activity-name">{act.name}</span>
{/if}
{#if act.notes}
<span class="opp-report-activity-notes">{act.notes}</span>
{/if}
</td>
<td>{act.assignTo?.name ?? act.cwEnteredBy ?? "System"}</td>
<td>{formatDateTime(act.cwDateEntered ?? act.dateStart)}</td>
<td>
{#if act.dateEnd}
{formatDateTime(act.dateEnd)}
{:else}
<span class="opp-report-open-badge">Open</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
<style>
.opp-report-tab {
padding: 0;
}
.opp-report-header {
margin-bottom: 16px;
}
.opp-report-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.opp-report-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
border-radius: 8px;
background: var(--bg-inset);
border: 1px solid var(--card-border);
}
.opp-report-card.opp-report-card-warning {
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.04);
}
.opp-report-card-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.opp-report-card-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.opp-report-section {
margin-bottom: 20px;
}
.opp-report-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 12px;
}
.opp-report-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 0;
color: var(--text-muted);
font-size: 13px;
}
.opp-report-error {
padding: 10px;
border-radius: 6px;
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.15);
color: #dc2626;
font-size: 12px;
}
.opp-report-empty {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
margin: 0;
}
.opp-report-table-wrap {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--card-border);
}
.opp-report-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.opp-report-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-inset);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
.opp-report-table td {
padding: 8px 12px;
vertical-align: top;
border-bottom: 1px solid var(--card-border);
color: var(--text-primary);
}
.opp-report-table tr:last-child td {
border-bottom: none;
}
.opp-report-type-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(59, 130, 246, 0.08);
color: #2563eb;
}
.opp-report-type-muted {
color: var(--text-muted);
}
.opp-report-activity-name {
display: block;
font-weight: 500;
}
.opp-report-activity-notes {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.opp-report-open-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
@media (max-width: 768px) {
.opp-report-cards {
grid-template-columns: 1fr 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,35 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type {
SalesOpportunity,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import type {
OpportunityNote,
OpportunityContact,
OpportunityProduct,
} from "../types";
import { formatDate, formatCurrency, statusColorClass } from "../types";
import {
formatDate,
formatDateTime,
formatCurrency,
statusColorClass,
} from "../types";
const dispatch = createEventDispatcher<{ selectProduct: number }>();
const dispatch = createEventDispatcher<{
selectProduct: number;
switchTab: string;
}>();
export let opportunity: SalesOpportunity | null;
export let notes: OpportunityNote[];
export let contacts: OpportunityContact[];
export let products: OpportunityProduct[];
export let permissions: PermissionMap = {};
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
// ── Active (non-cancelled) products ──
$: activeProducts = products.filter((p) => p.cancellationType !== "full");
@@ -75,38 +91,119 @@
return (p.quantity ?? 0) - (p.quantityCancelled ?? 0);
}
// ── Timeline entries — built dynamically from available dates ──
$: timeline = [
opportunity?.createdAt
? { label: "Created", date: opportunity.createdAt, icon: "created" }
: null,
opportunity?.dateBecameLead
? { label: "Became Lead", date: opportunity.dateBecameLead, icon: "lead" }
: null,
opportunity?.pipelineChangeDate
? {
label: "Pipeline Changed",
date: opportunity.pipelineChangeDate,
icon: "pipeline",
}
: null,
opportunity?.expectedCloseDate
? {
label: "Expected Close",
date: opportunity.expectedCloseDate,
icon: "target",
highlight: true,
}
: null,
opportunity?.closedDate
? { label: "Closed", date: opportunity.closedDate, icon: "closed" }
: null,
].filter(Boolean) as {
// ── Timeline entries — milestones + workflow activities, sorted chronologically ──
type TimelineEntry = {
label: string;
date: string;
icon: string;
kind: "milestone" | "activity";
dotClass?: string;
textClass?: string;
highlight?: boolean;
}[];
};
// Collapse threshold: show first N and last N when total > MAX
const TIMELINE_MAX = 8;
const TIMELINE_EDGE = 4;
$: timelineCollapsed = timeline.length > TIMELINE_MAX;
$: visibleTimeline = timelineCollapsed
? [...timeline.slice(0, TIMELINE_EDGE), ...timeline.slice(-TIMELINE_EDGE)]
: timeline;
$: hiddenCount = timelineCollapsed ? timeline.length - TIMELINE_EDGE * 2 : 0;
function getOptimaType(activity: OpportunityActivity): string | null {
const cf = activity.customFields?.find(
(f) => f.id === 45 || f.caption === "Optima_Type",
);
return cf?.value ?? null;
}
function activityDotClass(type: string | null): string {
const map: Record<string, string> = {
"Opportunity Created": "ov-dot-created",
"Opportunity Setup": "ov-dot-setup",
"Opportunity Review": "ov-dot-review",
"Quote Sent": "ov-dot-sent",
"Quote Confirmed": "ov-dot-confirmed",
"Quote Sent & Confirmed": "ov-dot-sent-confirmed",
Revision: "ov-dot-revision",
Finalized: "ov-dot-finalized",
Converted: "ov-dot-converted",
"Quote Generated": "ov-dot-generated",
};
return map[type ?? ""] ?? "ov-dot-default";
}
function activityTextClass(type: string | null): string {
const map: Record<string, string> = {
"Opportunity Created": "ov-text-created",
"Opportunity Setup": "ov-text-setup",
"Opportunity Review": "ov-text-review",
"Quote Sent": "ov-text-sent",
"Quote Confirmed": "ov-text-confirmed",
"Quote Sent & Confirmed": "ov-text-sent-confirmed",
Revision: "ov-text-revision",
Finalized: "ov-text-finalized",
Converted: "ov-text-converted",
"Quote Generated": "ov-text-generated",
};
return map[type ?? ""] ?? "";
}
$: timeline = (() => {
const entries: TimelineEntry[] = [];
// Milestones from opportunity 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",
date: opportunity.expectedCloseDate,
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),
});
}
// Sort chronologically (oldest first)
return entries.sort((a, b) => a.date.localeCompare(b.date));
})();
$: isClosedOpportunity = (() => {
if (!opportunity) return false;
@@ -174,12 +271,8 @@
if (popoverTimeout) clearTimeout(popoverTimeout);
hoveredProduct = product;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const tableWrap = (e.currentTarget as HTMLElement).closest(
".ov-forecast-table-wrap",
);
const wrapRect = tableWrap?.getBoundingClientRect() ?? rect;
popoverX = rect.left - wrapRect.left;
popoverY = rect.top - wrapRect.top - 4;
popoverX = rect.left;
popoverY = rect.top - 4;
}
function hidePopover() {
@@ -215,19 +308,23 @@
: ""}</span
>
</div>
<div class="ov-kpi-card">
<span class="ov-kpi-label">Cost</span>
<span class="ov-kpi-value">{formatCurrency(totalCost)}</span>
</div>
<div class="ov-kpi-card">
<span class="ov-kpi-label">Margin</span>
<span class="ov-kpi-value {marginHealthColor(marginPct)}"
>{formatCurrency(totalMargin)}</span
>
<span class="ov-kpi-pct {marginHealthColor(marginPct)}"
>{marginPct.toFixed(0)}%</span
>
</div>
{#if canViewCost}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Cost</span>
<span class="ov-kpi-value">{formatCurrency(totalCost)}</span>
</div>
{/if}
{#if canViewMargin}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Margin</span>
<span class="ov-kpi-value {marginHealthColor(marginPct)}"
>{formatCurrency(totalMargin)}</span
>
<span class="ov-kpi-pct {marginHealthColor(marginPct)}"
>{marginPct.toFixed(0)}%</span
>
</div>
{/if}
{#if totalTax > 0}
<div class="ov-kpi-card">
<span class="ov-kpi-label">Sales Tax</span>
@@ -276,19 +373,49 @@
</h3>
{#if timeline.length > 0}
<div class="ov-timeline">
{#each timeline as entry, i}
{#each visibleTimeline as entry, i}
{#if timelineCollapsed && i === TIMELINE_EDGE}
<!-- Collapsed gap -->
<button
class="ov-timeline-gap"
on:click={() => dispatch("switchTab", "Activity")}
title="View all in Activity tab"
>
<span class="ov-gap-pill">{hiddenCount} more events</span>
<svg
class="ov-gap-arrow"
viewBox="0 0 16 16"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M6 3l5 5-5 5" /></svg
>
</button>
{/if}
<div
class="ov-timeline-item"
class:last={i === timeline.length - 1}
class:last={i === visibleTimeline.length - 1}
class:highlight={entry.highlight}
class:ov-activity-item={entry.kind === "activity"}
>
<div
class="ov-timeline-dot"
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.label}</span>
<span class="ov-timeline-date">{formatDate(entry.date)}</span>
<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
>
</div>
</div>
{/each}
@@ -387,7 +514,9 @@
<tr>
<th class="col-product">Product</th>
<th class="col-revenue">Revenue</th>
<th class="col-margin">Margin</th>
{#if canViewMargin}
<th class="col-margin">Margin</th>
{/if}
</tr>
</thead>
<tbody>
@@ -432,22 +561,24 @@
</span>
</td>
<td class="col-revenue">{formatCurrency(p.revenue)}</td>
<td class="col-margin">
{#if p.cost && p.cost > 0}
<span
class="ov-margin-badge {marginHealthColor(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) * 100,
)}"
>
{(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) *
100
).toFixed(0)}%
</span>
{:else}
<span class="ov-margin-badge neutral"></span>
{/if}
</td>
{#if canViewMargin}
<td class="col-margin">
{#if p.cost && p.cost > 0}
<span
class="ov-margin-badge {marginHealthColor(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) * 100,
)}"
>
{(
((p.revenue - (p.cost ?? 0)) / (p.cost || 1)) *
100
).toFixed(0)}%
</span>
{:else}
<span class="ov-margin-badge neutral"></span>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
@@ -457,11 +588,15 @@
<td class="col-revenue"
><strong>{formatCurrency(totalRevenue)}</strong></td
>
<td class="col-margin">
<span class="ov-margin-badge {marginHealthColor(marginPct)}">
{marginPct.toFixed(0)}%
</span>
</td>
{#if canViewMargin}
<td class="col-margin">
<span
class="ov-margin-badge {marginHealthColor(marginPct)}"
>
{marginPct.toFixed(0)}%
</span>
</td>
{/if}
</tr>
</tfoot>
</table>
@@ -521,24 +656,28 @@
>{formatCurrency(hoveredProduct.revenue)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Cost</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.cost)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.margin)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin %</span>
<span class="ov-popover-fin-value"
>{productMarginPct(hoveredProduct)}</span
>
</div>
{#if canViewCost}
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Cost</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.cost)}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin</span>
<span class="ov-popover-fin-value"
>{formatCurrency(hoveredProduct.margin)}</span
>
</div>
<div class="ov-popover-fin-item">
<span class="ov-popover-fin-label">Margin %</span>
<span class="ov-popover-fin-value"
>{productMarginPct(hoveredProduct)}</span
>
</div>
{/if}
</div>
{#if hoveredProduct.cancellationType}
<div
@@ -25,9 +25,11 @@
export let productSequence: number[] | null = null;
export let initialProductId: number | null = null;
export let permissions: PermissionMap = {} as PermissionMap;
export let isClosedOpportunity: boolean = false;
$: canViewMargin = permissions["sales.opportunity.view_margin"] !== false;
$: canViewCost = permissions["sales.opportunity.view_cost"] !== false;
$: canViewProfit = permissions["sales.opportunity.view_profit"] !== false;
const dispatch = createEventDispatcher<{
sequenceSaved: number[];
@@ -211,6 +213,16 @@
let showCancelModal = false;
let isSavingCancellation = false;
let cancellationSaveError = "";
let showDeleteModal = false;
let isDeletingProduct = false;
let deleteProductError = "";
$: canDeleteProduct =
!isClosedOpportunity &&
permissions["sales.opportunity.product.delete"] !== false;
$: canUpdateProduct =
!isClosedOpportunity &&
permissions["sales.opportunity.product.update"] !== false;
let cancellationForm = {
quantityCancelled: "0",
cancellationReason: "",
@@ -220,7 +232,6 @@
unitCost: string;
quantity: string;
description: string;
customerDescription: string;
productNarrative: string;
procurementNotes: string;
} = {
@@ -228,7 +239,6 @@
unitCost: "",
quantity: "",
description: "",
customerDescription: "",
productNarrative: "",
procurementNotes: "",
};
@@ -242,7 +252,6 @@
unitCost: uc != null ? uc.toFixed(2) : "",
quantity: selectedProduct.quantity?.toString() ?? "",
description: selectedProduct.productDescription ?? "",
customerDescription: selectedProduct.customerDescription ?? "",
productNarrative: selectedProduct.productNarrative ?? "",
procurementNotes: selectedProduct.procurementNotes ?? "",
};
@@ -334,6 +343,44 @@
}
}
function openDeleteProductModal() {
if (!selectedProduct) return;
showActionMenu = false;
deleteProductError = "";
showDeleteModal = true;
}
function closeDeleteProductModal() {
if (isDeletingProduct) return;
showDeleteModal = false;
deleteProductError = "";
}
async function executeDeleteProduct() {
if (!selectedProduct || !accessToken || isDeletingProduct) return;
isDeletingProduct = true;
deleteProductError = "";
try {
await optima.sales.deleteProduct(
accessToken,
opportunityId,
selectedProduct.id,
);
showDeleteModal = false;
selectedProduct = null;
showPanel = false;
isClosing = false;
await refreshProducts();
} catch (err) {
console.error("[DeleteProduct] Failed:", err);
deleteProductError =
err instanceof Error ? err.message : "Failed to delete product";
} finally {
isDeletingProduct = false;
}
}
async function saveEdit() {
if (!selectedProduct || !accessToken || isSavingEdit) return;
@@ -344,7 +391,6 @@
const updates: {
quantity: number;
productDescription: string;
customerDescription: string | null;
productNarrative: string | null;
procurementNotes: string | null;
unitPrice?: number;
@@ -352,7 +398,6 @@
} = {
quantity: qty,
productDescription: editForm.description,
customerDescription: editForm.customerDescription || null,
productNarrative: editForm.productNarrative || null,
procurementNotes: editForm.procurementNotes || null,
};
@@ -544,10 +589,10 @@
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return "neutral";
const pct = ((margin ?? 0) / cost) * 100;
if (pct < 0) return "negative";
if (pct >= 30) return "healthy";
if (pct >= 15) return "moderate";
if (pct >= 0) return "low";
return "negative";
return "low";
}
function markupBarWidthPct(revenue?: number, margin?: number): number {
@@ -557,6 +602,12 @@
return Math.min(Math.abs(pct), 100);
}
function rawMarkupPct(revenue?: number, margin?: number): number {
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return 0;
return ((margin ?? 0) / cost) * 100;
}
function isNegativeMarkup(revenue?: number, margin?: number): boolean {
const cost = (revenue ?? 0) - (margin ?? 0);
if (!cost || cost <= 0) return false;
@@ -568,10 +619,10 @@
const rev = revenue ?? 0;
if (!rev || rev <= 0) return "neutral";
const pct = ((margin ?? 0) / rev) * 100;
if (pct < 0) return "negative";
if (pct >= 25) return "healthy";
if (pct >= 12) return "moderate";
if (pct >= 0) return "low";
return "negative";
return "low";
}
function marginBarWidthPct(revenue?: number, margin?: number): number {
@@ -581,6 +632,16 @@
return Math.min(Math.abs(pct), 100);
}
function rawMarginPct(revenue?: number, margin?: number): number {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return 0;
return ((margin ?? 0) / rev) * 100;
}
function tierWidth(rawPct: number, tierStart: number): number {
return Math.min(Math.max(rawPct - tierStart, 0), 100);
}
function isNegativeMargin(revenue?: number, margin?: number): boolean {
const rev = revenue ?? 0;
if (!rev || rev <= 0) return false;
@@ -724,7 +785,9 @@
}
isClosing = false;
showCancelModal = false;
showDeleteModal = false;
cancellationSaveError = "";
deleteProductError = "";
selectedProduct = p;
showPanel = true;
}
@@ -738,7 +801,9 @@
isEditing = false;
showActionMenu = false;
showCancelModal = false;
showDeleteModal = false;
cancellationSaveError = "";
deleteProductError = "";
isClosing = true;
setTimeout(() => {
selectedProduct = null;
@@ -749,6 +814,10 @@
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (showDeleteModal) {
closeDeleteProductModal();
return;
}
if (showCancelModal) {
closeCancellationModal();
return;
@@ -792,7 +861,33 @@
<div class="products-tab" class:has-detail={showPanel}>
{#if products.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No products on this opportunity" />
<div class="tab-empty-content">
<NoResultsMonkey message="No products on this opportunity" />
{#if !isClosedOpportunity}
<button
class="toolbar-add-btn empty-add-btn"
type="button"
on:click={() => (showAddProductModal = true)}
>
<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>
Add Product
</button>
{/if}
</div>
</div>
{:else}
<!-- ═══ Financial KPI Strip ═══ -->
@@ -873,53 +968,57 @@
</span>
</div>
{/if}
<div class="kpi-card">
<div class="kpi-icon profit">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path
d="M16 21V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v16"
/>
</svg>
{#if canViewProfit}
<div class="kpi-card">
<div class="kpi-icon profit">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path
d="M16 21V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v16"
/>
</svg>
</div>
<div class="kpi-content">
<span class="kpi-label">Profit</span>
<span class="kpi-value">{formatCurrency(totalProfit)}</span>
</div>
</div>
<div class="kpi-content">
<span class="kpi-label">Profit</span>
<span class="kpi-value">{formatCurrency(totalProfit)}</span>
</div>
</div>
{/if}
</div>
<!-- ═══ Products Toolbar ═══ -->
<div class="products-toolbar">
<div class="toolbar-left">
<button
class="toolbar-add-btn"
type="button"
on:click={() => (showAddProductModal = true)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
{#if !isClosedOpportunity}
<button
class="toolbar-add-btn"
type="button"
on:click={() => (showAddProductModal = true)}
>
<line x1="12" y1="5" x2="12" y2="19" /><line
x1="5"
y1="12"
x2="19"
y2="12"
/>
</svg>
Add Product
</button>
<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>
Add Product
</button>
{/if}
<button
class="view-toggle-btn"
type="button"
@@ -1013,7 +1112,7 @@
class:selected={selectedProduct?.id === p.id}
class:dragging={dragIndex === i}
data-drag-index={i}
draggable={!isSaving}
draggable={!isSaving && !isClosedOpportunity}
animate:flip={{ duration: 120 }}
on:dragstart={(e) => handleDragStart(e, i)}
on:dragover={(e) => handleDragOver(e, i)}
@@ -1077,12 +1176,14 @@
>{formatCurrency(unitPrice(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{#if canViewCost}
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
@@ -1140,7 +1241,7 @@
class:selected={selectedProduct?.id === p.id}
class:dragging={dragIndex === i}
data-drag-index={i}
draggable={!isSaving}
draggable={!isSaving && !isClosedOpportunity}
animate:flip={{ duration: 200 }}
on:dragstart={(e) => handleDragStart(e, i)}
on:dragover={(e) => handleDragOver(e, i)}
@@ -1227,12 +1328,14 @@
>{formatCurrency(unitPrice(p))}</span
>
</div>
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{#if canViewCost}
<div class="card-metric">
<span class="card-metric-label">Unit Cost</span>
<span class="card-metric-value"
>{formatCurrency(unitCost(p))}</span
>
</div>
{/if}
{#if canViewMargin}
<div class="card-metric">
<span class="card-metric-label">Margin</span>
@@ -1258,6 +1361,33 @@
p.margin,
)}%"
></div>
{#if rawMarkupPct(p.revenue, p.margin) > 100}
<div
class="card-margin-fill super-blue"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
100,
)}%"
></div>
{/if}
{#if rawMarkupPct(p.revenue, p.margin) > 200}
<div
class="card-margin-fill super-indigo"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
200,
)}%"
></div>
{/if}
{#if rawMarkupPct(p.revenue, p.margin) > 300}
<div
class="card-margin-fill super-violet"
style="width: {tierWidth(
rawMarkupPct(p.revenue, p.margin),
300,
)}%"
></div>
{/if}
</div>
</div>
<div class="card-bar-row">
@@ -1277,6 +1407,33 @@
p.margin,
)}%"
></div>
{#if rawMarginPct(p.revenue, p.margin) > 100}
<div
class="card-margin-fill super-blue"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
100,
)}%"
></div>
{/if}
{#if rawMarginPct(p.revenue, p.margin) > 200}
<div
class="card-margin-fill super-indigo"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
200,
)}%"
></div>
{/if}
{#if rawMarginPct(p.revenue, p.margin) > 300}
<div
class="card-margin-fill super-violet"
style="width: {tierWidth(
rawMarginPct(p.revenue, p.margin),
300,
)}%"
></div>
{/if}
</div>
</div>
</div>
@@ -1530,91 +1687,121 @@
</div>
</div>
<div class="detail-header-actions">
{#if isEditing}
<button
class="detail-action-btn cancel-btn"
on:click={cancelEdit}
type="button"
title="Discard changes"
disabled={isSavingEdit}
>
Cancel
</button>
<button
class="detail-action-btn save-btn"
on:click={saveEdit}
type="button"
title="Save changes"
disabled={isSavingEdit}
>
{isSavingEdit ? "Saving..." : "Save"}
</button>
{:else}
<button
class="detail-action-btn cancel-state-btn"
class:active={hasCancellation(selectedProduct)}
on:click={openCancellationModal}
type="button"
title={hasCancellation(selectedProduct)
? "Edit cancellation"
: "Cancel product"}
disabled={maxCancellationQty(selectedProduct) <= 0}
>
{hasCancellation(selectedProduct)
? "Edit Cancellation"
: "Cancel"}
</button>
<div class="detail-menu-wrap">
{#if !isClosedOpportunity}
{#if isEditing}
<button
class="detail-menu-btn"
on:click={toggleActionMenu}
class="detail-action-btn cancel-btn"
on:click={cancelEdit}
type="button"
aria-label="Product actions"
title="Actions"
title="Discard changes"
disabled={isSavingEdit}
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="18"
height="18"
>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
Cancel
</button>
{#if showActionMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="detail-action-menu"
on:click|stopPropagation
<button
class="detail-action-btn save-btn"
on:click={saveEdit}
type="button"
title="Save changes"
disabled={isSavingEdit}
>
{isSavingEdit ? "Saving..." : "Save"}
</button>
{:else}
{#if canUpdateProduct}
<button
class="detail-action-btn cancel-state-btn"
class:active={hasCancellation(selectedProduct)}
on:click={openCancellationModal}
type="button"
title={hasCancellation(selectedProduct)
? "Edit cancellation"
: "Cancel product"}
disabled={maxCancellationQty(selectedProduct) <= 0}
>
<button
class="detail-action-menu-item"
on:click={enterEditMode}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit Product
</button>
</div>
{hasCancellation(selectedProduct)
? "Edit Cancellation"
: "Cancel"}
</button>
{/if}
</div>
<div class="detail-menu-wrap">
<button
class="detail-menu-btn"
on:click={toggleActionMenu}
type="button"
aria-label="Product actions"
title="Actions"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
width="18"
height="18"
>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</button>
{#if showActionMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="detail-action-menu"
on:click|stopPropagation
>
{#if canUpdateProduct}
<button
class="detail-action-menu-item"
on:click={enterEditMode}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Edit Product
</button>
{/if}
{#if canDeleteProduct}
<button
class="detail-action-menu-item danger"
on:click={openDeleteProductModal}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
Delete Product
</button>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
<button
class="detail-close"
@@ -1858,12 +2045,14 @@
>
</div>
{/if}
<div class="detail-finance-card">
<span class="detail-finance-label">Profit</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.profit)}</span
>
</div>
{#if canViewProfit}
<div class="detail-finance-card">
<span class="detail-finance-label">Profit</span>
<span class="detail-finance-value"
>{formatCurrency(selectedProduct.profit)}</span
>
</div>
{/if}
</div>
{#if canViewMargin}
@@ -1911,6 +2100,42 @@
selectedProduct.margin,
)}%"
></div>
{#if rawMarkupPct(selectedProduct.revenue, selectedProduct.margin) > 100}
<div
class="detail-margin-fill super-blue"
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
),
100,
)}%"
></div>
{/if}
{#if rawMarkupPct(selectedProduct.revenue, selectedProduct.margin) > 200}
<div
class="detail-margin-fill super-indigo"
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
),
200,
)}%"
></div>
{/if}
{#if rawMarkupPct(selectedProduct.revenue, selectedProduct.margin) > 300}
<div
class="detail-margin-fill super-violet"
style="width: {tierWidth(
rawMarkupPct(
selectedProduct.revenue,
selectedProduct.margin,
),
300,
)}%"
></div>
{/if}
</div>
<span class="detail-margin-pct">
{selectedProduct.cost
@@ -1940,6 +2165,42 @@
selectedProduct.margin,
)}%"
></div>
{#if rawMarginPct(selectedProduct.revenue, selectedProduct.margin) > 100}
<div
class="detail-margin-fill super-blue"
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
),
100,
)}%"
></div>
{/if}
{#if rawMarginPct(selectedProduct.revenue, selectedProduct.margin) > 200}
<div
class="detail-margin-fill super-indigo"
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
),
200,
)}%"
></div>
{/if}
{#if rawMarginPct(selectedProduct.revenue, selectedProduct.margin) > 300}
<div
class="detail-margin-fill super-violet"
style="width: {tierWidth(
rawMarginPct(
selectedProduct.revenue,
selectedProduct.margin,
),
300,
)}%"
></div>
{/if}
</div>
<span class="detail-margin-pct">
{selectedProduct.revenue
@@ -1975,21 +2236,6 @@
</svg>
Details
</div>
<div class="detail-field">
<span class="detail-field-label">Customer Description</span>
{#if isEditing}
<textarea
class="edit-textarea"
bind:value={editForm.customerDescription}
placeholder="Customer-facing description…"
rows="2"
></textarea>
{:else}
<span class="detail-field-value"
>{selectedProduct.customerDescription || "—"}</span
>
{/if}
</div>
<div class="detail-field">
<span class="detail-field-label">Product Narrative</span>
{#if isEditing}
@@ -2334,6 +2580,69 @@
</div>
{/if}
{#if showDeleteModal && selectedProduct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="cancel-modal-overlay" on:click={closeDeleteProductModal}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="cancel-modal" on:click|stopPropagation>
<div class="cancel-modal-header">
<h4>Delete Product</h4>
<button
class="cancel-modal-close"
type="button"
on:click={closeDeleteProductModal}
aria-label="Close delete modal"
disabled={isDeletingProduct}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="cancel-modal-copy">
Are you sure you want to delete <strong
>{selectedProduct.productDescription ??
selectedProduct.forecastDescription ??
"this product"}</strong
>? This action cannot be undone.
</p>
{#if deleteProductError}
<div class="cancel-modal-error">{deleteProductError}</div>
{/if}
<div class="cancel-modal-actions">
<button
class="detail-action-btn cancel-btn"
type="button"
on:click={closeDeleteProductModal}
disabled={isDeletingProduct}
>
Cancel
</button>
<button
class="detail-action-btn delete-confirm-btn"
type="button"
on:click={executeDeleteProduct}
disabled={isDeletingProduct}
>
{isDeletingProduct ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
{/if}
<AddProductModal
bind:isOpen={showAddProductModal}
accessToken={accessToken ?? ""}
@@ -3015,16 +3324,21 @@
background: var(--bg-muted, rgba(128, 128, 128, 0.12));
overflow: hidden;
min-width: 40px;
position: relative;
}
.card-margin-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 2px;
border-radius: 9999px;
transition: width 0.4s ease;
}
.card-margin-fill.from-right {
margin-left: auto;
left: auto;
right: 0;
}
.card-margin-fill.healthy {
@@ -3047,6 +3361,18 @@
background: var(--text-faint, #888);
}
.card-margin-fill.super-blue {
background: #3b82f6;
}
.card-margin-fill.super-indigo {
background: #6366f1;
}
.card-margin-fill.super-violet {
background: #8b5cf6;
}
/* Card chevron */
.card-chevron {
display: flex;
@@ -3335,6 +3661,29 @@
color: var(--text-primary);
}
.detail-action-menu-item.danger {
color: #ef4444;
}
.detail-action-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.delete-confirm-btn {
background: #ef4444;
color: #fff;
}
.delete-confirm-btn:hover:not(:disabled) {
background: #dc2626;
}
.delete-confirm-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* Edit mode action buttons */
.detail-action-btn {
padding: 5px 12px;
@@ -3804,16 +4153,21 @@
border-radius: 3px;
background: var(--bg-muted, rgba(128, 128, 128, 0.12));
overflow: hidden;
position: relative;
}
.detail-margin-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 3px;
border-radius: 9999px;
transition: width 0.4s ease;
}
.detail-margin-fill.from-right {
margin-left: auto;
left: auto;
right: 0;
}
.detail-margin-fill.healthy {
@@ -3832,6 +4186,16 @@
background: var(--text-faint, #888);
}
.detail-margin-fill.super-blue {
background: #3b82f6;
}
.detail-margin-fill.super-indigo {
background: #6366f1;
}
.detail-margin-fill.super-violet {
background: #8b5cf6;
}
.detail-margin-pct {
font-size: 11px;
font-weight: 600;
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { createEventDispatcher, onDestroy, onMount } from "svelte";
import { PUBLIC_API_URL } from "$env/static/public";
import { io, type Socket } from "socket.io-client";
import {
@@ -14,11 +14,17 @@
export let opportunityId: string = "";
export let quotePreviewPdfUrl: string | null = null;
export let initialQuotes: CommittedQuote[] = [];
export let initialQuoteId: string | null = null;
export let permissions: PermissionMap = {} as PermissionMap;
export let isClosedOpportunity: boolean = false;
const dispatch = createEventDispatcher<{ quotesChanged: CommittedQuote[] }>();
// ── Permission helpers ──
$: canFetchQuotes = permissions["sales.opportunity.quote.fetch"] !== false;
$: canCommitQuote = permissions["sales.opportunity.quote.commit"] === true;
$: canCommitQuote =
!isClosedOpportunity &&
permissions["sales.opportunity.quote.commit"] === true;
$: canPreviewQuote = permissions["sales.opportunity.quote.preview"] === true;
$: canDownloadQuote =
permissions["sales.opportunity.quote.download"] === true;
@@ -29,8 +35,35 @@
let quotes: CommittedQuote[] = initialQuotes;
let quotesLoading = false;
let quotesError = "";
// Determine initial selection: prefer initialQuoteId match, fall back to first quote
const initialMatch = initialQuoteId
? initialQuotes.find(
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId,
)
: null;
console.log(
"[QuotesTab] initialQuoteId:",
initialQuoteId,
"quotes:",
initialQuotes.map((q) => ({ id: q.id, fileName: q.quoteFileName })),
"match:",
initialMatch?.id,
);
let selectedQuote: CommittedQuote | null =
initialQuotes.length > 0 ? initialQuotes[0] : null;
initialMatch ?? (initialQuotes.length > 0 ? initialQuotes[0] : null);
// 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,
);
if (match) {
selectedQuote = match;
viewMode = "list";
loadQuotePreview(match.id);
}
}
// ── Detail data (lazy-loaded with regen data & params) ──
let detailQuotes: Map<string, CommittedQuote> = new Map();
@@ -145,6 +178,7 @@
try {
const result = await sales.fetchQuotes(accessToken, opportunityId);
quotes = result.data ?? [];
dispatch("quotesChanged", quotes);
if (quotes.length > 0 && !selectedQuote) {
selectedQuote = quotes[0];
}
@@ -0,0 +1,372 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
ReviewDecision,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
sendQuote: void;
close: void;
}>();
export let isOpen = false;
export let canCancel = false;
export let canSend = true;
export let error: string | null = null;
export let isSubmitting = false;
let selectedDecision: ReviewDecision | null = null;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let noteError = "";
let showSendQuoteConfirm = false;
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
const decisions: {
key: ReviewDecision;
label: string;
desc: string;
class: string;
}[] = [
{
key: "approve",
label: "Approve",
desc: "Approve and move to Pending Sent",
class: "wf-decision-approve",
},
{
key: "reject",
label: "Reject",
desc: "Send back for revision",
class: "wf-decision-reject",
},
{
key: "send",
label: "Send Quote",
desc: "Approve and send quote immediately",
class: "wf-decision-send",
},
{
key: "cancel",
label: "Cancel",
desc: "Cancel the opportunity",
class: "wf-decision-cancel",
},
];
function selectDecision(d: ReviewDecision) {
if (d === "cancel" && !canCancel) return;
if (d === "send" && !canSend) return;
selectedDecision = d;
noteError = "";
showSendQuoteConfirm = false;
}
function validate(): boolean {
noteError = "";
if (!note.trim()) {
noteError = "A note is required for review decisions.";
return false;
}
return true;
}
function handleSubmit() {
if (!selectedDecision) return;
if (!validate()) return;
// If "send" was chosen, confirm then transition to SendQuote modal
if (selectedDecision === "send") {
showSendQuoteConfirm = true;
return;
}
const payload: WorkflowActionPayload = {
decision: selectedDecision,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleSendQuoteConfirm() {
// First submit the review decision with "send", then open SendQuote modal
const payload: WorkflowActionPayload = {
decision: "send" as ReviewDecision,
};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
selectedDecision = null;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
noteError = "";
showSendQuoteConfirm = false;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Finish Review"
tabindex="-1"
>
<div class="wf-modal wf-modal-wide" on:click|stopPropagation>
<div class="wf-modal-header wf-header-accent">
<h3 class="wf-modal-title">Finish Review</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
{#if showSendQuoteConfirm}
<!-- Send Quote confirmation step -->
<div class="wf-confirmation-box">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
<p>
This will approve the review and send the quote. The send quote
form will allow you to set additional options.
</p>
<div class="wf-confirmation-actions">
<button
class="wf-btn wf-btn-secondary"
on:click={() => (showSendQuoteConfirm = false)}
disabled={isSubmitting}
>
Go Back
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSendQuoteConfirm}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Continue to Send Quote
{/if}
</button>
</div>
</div>
{:else}
<!-- Decision selector -->
<div class="wf-decision-group">
<span class="wf-field-label">Decision</span>
<div class="wf-decision-buttons">
{#each decisions as d (d.key)}
{@const isDisabled =
(d.key === "cancel" && !canCancel) ||
(d.key === "send" && !canSend)}
<button
class="wf-decision-btn {d.class}"
class:wf-selected={selectedDecision === d.key}
class:wf-disabled-perm={isDisabled}
on:click={() => selectDecision(d.key)}
disabled={isSubmitting || isDisabled}
title={isDisabled
? d.key === "cancel"
? "You don't have permission to cancel opportunities"
: "You don't have permission to send quotes"
: d.desc}
>
{d.label}
</button>
{/each}
</div>
{#if selectedDecision}
<span class="wf-decision-desc">
{decisions.find((d) => d.key === selectedDecision)?.desc ?? ""}
</span>
{/if}
</div>
<!-- Note field (always required for review) -->
<div class="wf-field-group">
<label class="wf-field-label" for="rd-note">
Note <span class="wf-required">*</span>
</label>
<textarea
id="rd-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder="Explain the review decision..."
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="rd-time-start">Start</label
>
<input
id="rd-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="rd-time-end">End</label>
<input
id="rd-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if !showSendQuoteConfirm}
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSubmit}
disabled={isSubmitting || !selectedDecision}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Submit Decision
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,377 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowAction,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let canFinalize = false;
export let error: string | null = null;
export let isSubmitting = false;
export let actionName: WorkflowAction = "sendQuote";
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
let quoteConfirmed = false;
// Sub-option: only one can be selected at a time
type SubOption = "won" | "lost" | "revision" | null;
let selectedSubOption: SubOption = null;
let finalizeImmediately = canFinalize;
// Reset finalize toggle when canFinalize changes
$: finalizeImmediately = canFinalize;
function toggleConfirmed() {
quoteConfirmed = !quoteConfirmed;
if (!quoteConfirmed) selectedSubOption = null;
}
function selectSub(opt: SubOption) {
if (!quoteConfirmed) return;
selectedSubOption = selectedSubOption === opt ? null : opt;
}
function handleSubmit() {
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
payload.quoteConfirmed = quoteConfirmed;
payload.won = selectedSubOption === "won";
payload.lost = selectedSubOption === "lost";
payload.needsRevision = selectedSubOption === "revision";
if (
(selectedSubOption === "won" || selectedSubOption === "lost") &&
canFinalize
) {
payload.finalize = finalizeImmediately;
}
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
quoteConfirmed = false;
selectedSubOption = null;
finalizeImmediately = canFinalize;
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
$: title = actionName === "resendQuote" ? "Re-Send Quote" : "Send Quote";
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal wf-modal-wide" on:click|stopPropagation>
<div class="wf-modal-header">
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Note field -->
<div class="wf-field-group">
<label class="wf-field-label" for="sq-note">
Note <span class="wf-optional">(optional)</span>
</label>
<textarea
id="sq-note"
class="wf-textarea"
bind:value={note}
placeholder="Add a note about this quote..."
rows="3"
></textarea>
</div>
<!-- Quote outcome options -->
<div class="sq-outcome-section">
<button
class="sq-confirmed-toggle"
class:sq-active={quoteConfirmed}
on:click={toggleConfirmed}
disabled={isSubmitting}
type="button"
>
<span class="sq-toggle-check">
{#if quoteConfirmed}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="14"
height="14"><polyline points="20 6 9 17 4 12" /></svg
>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="14"
height="14"
opacity="0.3"><circle cx="12" cy="12" r="9" /></svg
>
{/if}
</span>
<span class="sq-toggle-label">Quote Confirmed</span>
<span class="sq-toggle-hint">Customer confirmed receipt</span>
</button>
{#if quoteConfirmed}
<div class="sq-sub-options" transition:slide={{ duration: 180 }}>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "won"}
on:click={() => selectSub("won")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline
points="22 4 12 14.01 9 11.01"
/></svg
>
<span class="sq-sub-label">Won</span>
<span class="sq-sub-desc">
{canFinalize ? "Finalize as won" : "Send to Pending Won"}
</span>
</button>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "lost"}
on:click={() => selectSub("lost")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" /></svg
>
<span class="sq-sub-label">Lost</span>
<span class="sq-sub-desc">Send to Pending Lost</span>
</button>
<button
class="sq-sub-option"
class:sq-sub-active={selectedSubOption === "revision"}
on:click={() => selectSub("revision")}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
><path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/><path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/></svg
>
<span class="sq-sub-label">Needs Revision</span>
<span class="sq-sub-desc"
>Requires changes before finalizing</span
>
</button>
</div>
{#if (selectedSubOption === "won" || selectedSubOption === "lost") && canFinalize}
<div class="sq-finalize-row" transition:slide={{ duration: 150 }}>
<label class="sq-finalize-check">
<input type="checkbox" bind:checked={finalizeImmediately} />
<span>Finalize immediately</span>
<span class="sq-finalize-hint"
>{finalizeImmediately
? "Will finalize directly"
: "Will go to pending review"}</span
>
</label>
</div>
{/if}
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="sq-time-start">Start</label>
<input
id="sq-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="sq-time-end">End</label>
<input
id="sq-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
</div>
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn wf-btn-primary"
on:click={handleSubmit}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,304 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import type {
WorkflowAction,
WorkflowActionPayload,
} from "$lib/optima-api/modules/sales";
const dispatch = createEventDispatcher<{
submit: WorkflowActionPayload;
close: void;
}>();
export let isOpen = false;
export let title = "Workflow Action";
export let actionName: WorkflowAction;
export let requiresNote = false;
export let needsConfirmation = false;
export let confirmationMessage = "";
export let isDestructive = false;
export let error: string | null = null;
export let isSubmitting = false;
let note = "";
let includeTimeEntry = false;
let timeStarted = "";
let timeEnded = "";
let showConfirmation = false;
let noteError = "";
function toLocalDatetime(d: Date): string {
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${da}T${h}:${mi}`;
}
function initTimes() {
const now = new Date();
const mins = now.getMinutes();
const roundedUp = Math.ceil(mins / 15) * 15;
const ended = new Date(now);
ended.setMinutes(roundedUp, 0, 0);
const started = new Date(ended.getTime() - 15 * 60 * 1000);
timeEnded = toLocalDatetime(ended);
timeStarted = toLocalDatetime(started);
}
initTimes();
function validate(): boolean {
noteError = "";
if (requiresNote && !note.trim()) {
noteError = "A note is required for this action.";
return false;
}
return true;
}
function handleSubmit() {
if (!validate()) return;
if (needsConfirmation && !showConfirmation) {
showConfirmation = true;
return;
}
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleConfirm() {
const payload: WorkflowActionPayload = {};
if (note.trim()) payload.note = note.trim();
if (includeTimeEntry && timeStarted)
payload.timeStarted = new Date(timeStarted).toISOString();
if (includeTimeEntry && timeEnded)
payload.timeEnded = new Date(timeEnded).toISOString();
dispatch("submit", payload);
}
function handleClose() {
if (isSubmitting) return;
note = "";
includeTimeEntry = false;
timeStarted = "";
timeEnded = "";
showConfirmation = false;
noteError = "";
dispatch("close");
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
function handleBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("wf-modal-backdrop")) {
handleClose();
}
}
</script>
{#if isOpen}
<div
class="wf-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="wf-modal" on:click|stopPropagation>
<div class="wf-modal-header" class:wf-destructive={isDestructive}>
<h3 class="wf-modal-title">{title}</h3>
<button
class="wf-modal-close"
on:click={handleClose}
disabled={isSubmitting}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
<div class="wf-modal-body">
{#if error}
<div class="wf-inline-error">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="12" cy="12" r="10" /><line
x1="15"
y1="9"
x2="9"
y2="15"
/><line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{error}</span>
</div>
{/if}
{#if showConfirmation}
<div class="wf-confirmation-box">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" /><line
x1="12"
y1="17"
x2="12.01"
y2="17"
/>
</svg>
<p>{confirmationMessage}</p>
<div class="wf-confirmation-actions">
<button
class="wf-btn wf-btn-secondary"
on:click={() => (showConfirmation = false)}
disabled={isSubmitting}
>
Go Back
</button>
<button
class="wf-btn {isDestructive
? 'wf-btn-destructive'
: 'wf-btn-primary'}"
on:click={handleConfirm}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
Confirm
{/if}
</button>
</div>
</div>
{:else}
<!-- Note field -->
<div class="wf-field-group">
<label class="wf-field-label" for="wf-note">
Note {#if requiresNote}<span class="wf-required">*</span>{/if}
</label>
<textarea
id="wf-note"
class="wf-textarea"
class:wf-field-error={!!noteError}
bind:value={note}
placeholder={requiresNote
? "Required — describe this action"
: "Optional note"}
rows="4"
></textarea>
{#if noteError}
<span class="wf-field-error-text">{noteError}</span>
{/if}
</div>
<!-- Time entry section -->
<div class="wf-time-section">
<label class="wf-time-toggle">
<input type="checkbox" bind:checked={includeTimeEntry} />
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="10" /><polyline
points="12 6 12 12 16 14"
/></svg
>
<span>Log time</span>
</label>
{#if includeTimeEntry}
<div class="wf-time-fields" transition:slide={{ duration: 200 }}>
<div class="wf-time-field">
<label class="wf-field-label" for="wf-time-start">Start</label
>
<input
id="wf-time-start"
type="datetime-local"
class="wf-input"
bind:value={timeStarted}
/>
</div>
<div class="wf-time-field">
<label class="wf-field-label" for="wf-time-end">End</label>
<input
id="wf-time-end"
type="datetime-local"
class="wf-input"
bind:value={timeEnded}
/>
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if !showConfirmation}
<div class="wf-modal-footer">
<button
class="wf-btn wf-btn-secondary"
on:click={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
class="wf-btn {isDestructive
? 'wf-btn-destructive'
: 'wf-btn-primary'}"
on:click={handleSubmit}
disabled={isSubmitting}
>
{#if isSubmitting}
<span class="wf-spinner"></span>
{:else}
{title}
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,550 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type {
WorkflowStatusResponse,
WorkflowAvailableAction,
WorkflowAction,
WorkflowActionPayload,
WorkflowResult,
} from "$lib/optima-api/modules/sales";
import {
WORKFLOW_STATUS_LABELS,
STATUS_ID_TO_KEY,
TERMINAL_STATUSES,
REOPENABLE_STATUSES,
QUOTE_CONFIRMED_STATUSES,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import type {
CommittedQuote,
OpportunityActivity,
} from "$lib/optima-api/modules/sales";
import WorkflowActionModal from "./WorkflowActionModal.svelte";
import SendQuoteModal from "./SendQuoteModal.svelte";
import ReviewDecisionModal from "./ReviewDecisionModal.svelte";
import FinalizeModal from "./FinalizeModal.svelte";
const dispatch = createEventDispatcher();
export let opportunity: SalesOpportunity | null;
export let workflowStatus: WorkflowStatusResponse | null;
export let permissions: PermissionMap;
export let opportunityId: string;
export let inline: boolean = false;
export let quotes: CommittedQuote[] = [];
export let activities: OpportunityActivity[] = [];
// Workflow state
let activeModal: WorkflowAction | null = null;
let workflowError: string | null = null;
let isSubmitting = false;
let preselectedOutcome: "won" | "lost" | null = null;
// Derived state
$: statusKey = workflowStatus
? (STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null)
: null;
$: statusLabel = statusKey
? WORKFLOW_STATUS_LABELS[statusKey]
: (workflowStatus?.currentStatus ?? opportunity?.status?.name ?? "Unknown");
$: isTerminal = statusKey ? TERMINAL_STATUSES.has(statusKey) : false;
$: isReopenable = statusKey ? REOPENABLE_STATUSES.has(statusKey) : false;
$: quoteConfirmed = statusKey
? QUOTE_CONFIRMED_STATUSES.has(statusKey)
: false;
$: isOptimaStage = workflowStatus?.isOptimaStage ?? false;
$: isCold = workflowStatus?.coldCheck?.isCold ?? false;
$: isPending =
statusKey === "PendingWon" ||
statusKey === "PendingLost" ||
statusKey === "PendingSent" ||
statusKey === "PendingRevision" ||
statusKey === "PendingNew";
$: isPendingOutcome =
statusKey === "PendingWon" || statusKey === "PendingLost";
$: canFinalize = permissions["sales.opportunity.finalize"] !== false;
$: canWin = permissions["sales.opportunity.win"] === true;
$: canLose = permissions["sales.opportunity.lose"] === true;
$: canCancel = permissions["sales.opportunity.cancel"] !== false;
$: canReview = permissions["sales.opportunity.review"] !== false;
$: canSend = permissions["sales.opportunity.send"] !== false;
$: canReopen = permissions["sales.opportunity.reopen"] !== false;
$: hasQuotes = quotes.length > 0;
/** For resendQuote: check that a quote was generated after the last revision opening */
$: hasQuoteSinceRevision = (() => {
if (!hasQuotes) return false;
// Find the most recent "Revision" or "beginRevision" activity by looking at
// customFields Optima_Type === "Revision" on activities
const revisionActivities = activities.filter((a) => {
const optimaType = a.customFields?.find(
(f) => f.caption === "Optima_Type",
)?.value;
return optimaType === "Revision";
});
if (revisionActivities.length === 0) return true; // no revisions, any quote is fine
// Get the most recent revision date
const latestRevisionDate = revisionActivities.reduce((latest, a) => {
const d = a.cwDateEntered ?? a.dateStart ?? "";
return d > latest ? d : latest;
}, "");
if (!latestRevisionDate) return true;
// Check if any quote was created after the latest revision
return quotes.some((q) => q.createdAt > latestRevisionDate);
})();
/** Reason text for disabled send/resend buttons */
$: sendQuoteDisabledReason = !hasQuotes
? "Generate a quote before sending"
: null;
$: resendQuoteDisabledReason = !hasQuotes
? "Generate a quote before sending"
: !hasQuoteSinceRevision
? "Generate a new quote before resending"
: null;
$: availableActions = (
workflowStatus?.availableActions?.filter((a) => a.permitted) ?? []
).filter((a) => actionPermitted(a.action));
/** Synthesize a resurrect action for PendingWon/PendingLost when user has finalize perm */
$: resurrectFromPending =
isPendingOutcome &&
canFinalize &&
!availableActions.some((a) => a.action === "resurrect")
? ({
action: "resurrect" as WorkflowAction,
label: "Open for Revision",
targetStatuses: [{ key: "Active", id: 58 }],
requiresNote: true,
requiresPermission: "sales.opportunity.finalize",
permitted: true,
payloadHints: {} as Record<string, string>,
} satisfies WorkflowAvailableAction)
: null;
/** All actions including the synthesized resurrect */
$: allActions = resurrectFromPending
? [...availableActions, resurrectFromPending]
: availableActions;
/** Map workflow action names to their required permission */
function actionPermitted(action: WorkflowAction): boolean {
switch (action) {
case "requestReview":
case "reviewDecision":
return canReview;
case "sendQuote":
case "resendQuote":
return canSend;
case "finalize":
return canFinalize || canWin || canLose;
case "cancel":
return canCancel;
case "reopen":
return canReopen;
case "resurrect":
// From PendingWon/PendingLost requires finalize perm; from terminal requires reopen
return (isPendingOutcome && canFinalize) || canReopen;
default:
return true; // confirmQuote, beginRevision, acceptNew — base workflow perm
}
}
/** CSS class for the workflow status badge */
function workflowStatusClass(key: string | null): string {
if (!key) return "wf-status-default";
const map: Record<string, string> = {
PendingNew: "wf-status-pending-new",
New: "wf-status-new",
InternalReview: "wf-status-review",
QuoteSent: "wf-status-quote-sent",
ConfirmedQuote: "wf-status-quote-confirmed",
Active: "wf-status-active",
PendingSent: "wf-status-pending-sent",
PendingRevision: "wf-status-pending-revision",
PendingWon: "wf-status-pending-won",
Won: "wf-status-won",
PendingLost: "wf-status-pending-lost",
Lost: "wf-status-lost",
Canceled: "wf-status-canceled",
};
return map[key] ?? "wf-status-default";
}
/** Forward-progression actions in priority order */
const FORWARD_ACTIONS: WorkflowAction[] = [
"acceptNew",
"requestReview",
"reviewDecision",
"sendQuote",
"resendQuote",
"confirmQuote",
"beginRevision",
];
/** Get display label for an action */
function actionLabel(action: WorkflowAvailableAction): string {
// Context-dependent label for resurrect
if (action.action === "resurrect" && isPendingOutcome) {
return "Open for Revision";
}
const overrides: Partial<Record<WorkflowAction, string>> = {
requestReview: "Request Review",
reviewDecision: "Complete Review",
sendQuote: "Send Quote",
resendQuote: "Resend Quote",
confirmQuote: "Confirm Quote",
beginRevision: "Revise",
resurrect: "Resurrect",
cancel: "Cancel",
reopen: "Reopen",
acceptNew: "Accept",
};
return overrides[action.action] ?? action.label;
}
/** Open the appropriate modal for an action */
function openAction(action: WorkflowAvailableAction) {
workflowError = null;
activeModal = action.action;
}
function closeModal() {
activeModal = null;
workflowError = null;
preselectedOutcome = null;
}
/** Submit a workflow action */
async function submitAction(
action: WorkflowAction,
payload: WorkflowActionPayload,
) {
isSubmitting = true;
workflowError = null;
try {
const res = await fetch(`/sales/opportunity/${opportunityId}/workflow`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, payload }),
});
const json = await res.json();
if (!res.ok || json.successful === false) {
workflowError =
json.message ?? json.data?.error ?? "Workflow action failed";
return null;
}
// Success — close modal, merge new activities for immediate gating update, and refresh
closeModal();
const result = json.data as WorkflowResult;
if (result.activitiesCreated?.length) {
activities = [...result.activitiesCreated, ...activities];
}
dispatch("workflowChanged", result);
return result;
} catch (err) {
workflowError =
err instanceof Error ? err.message : "Workflow action failed";
return null;
} finally {
isSubmitting = false;
}
}
/** Handle SendQuote modal wanting to chain into review */
function handleSendQuoteChain(e: CustomEvent<{ action: WorkflowAction }>) {
activeModal = e.detail.action;
}
/** Determine if action needs a special modal */
function isSpecialModal(action: WorkflowAction): boolean {
return ["sendQuote", "resendQuote", "reviewDecision", "finalize"].includes(
action,
);
}
/** Actions that need a confirmation dialog before submission */
function needsConfirmation(action: WorkflowAction): boolean {
return action === "cancel" || action === "reopen" || action === "resurrect";
}
/** Confirmation messages */
function confirmationMessage(action: WorkflowAction): string {
if (action === "cancel") {
return "This cannot be undone unless the opportunity is re-opened. Are you sure?";
}
if (action === "reopen") {
return "Re-opening will set status to Active and log a full audit trail entry. Continue?";
}
if (action === "resurrect") {
return "This will move the opportunity back to Active for further revision. Continue?";
}
return "";
}
/** Get the finalize actions split by outcome (only after quote confirmed, gated by win/lose perms) */
$: finalizeWonAction =
quoteConfirmed && canWin
? allActions.find(
(a) =>
a.action === "finalize" &&
(a.payloadHints as Record<string, string>)?.["outcome"] === '"won"',
)
: undefined;
$: finalizeLostAction =
quoteConfirmed && canLose
? allActions.find(
(a) =>
a.action === "finalize" &&
(a.payloadHints as Record<string, string>)?.["outcome"] ===
'"lost"',
)
: undefined;
$: nonFinalizeActions = allActions
.filter((a) => a.action !== "finalize")
.filter((a, i, arr) => arr.findIndex((b) => b.action === a.action) === i);
/** The main forward-progression action (shown as filled primary button) */
$: primaryAction =
nonFinalizeActions.find((a) => FORWARD_ACTIONS.includes(a.action)) ?? null;
/** Everything else (shown as ghost buttons) */
$: secondaryActions = nonFinalizeActions.filter((a) => a !== primaryAction);
</script>
{#if workflowStatus && isOptimaStage}
<div class="wf-panel" class:wf-panel-inline={inline}>
<!-- Error Banner -->
{#if workflowError}
<div class="wf-error-banner">
<span class="wf-error-text">{workflowError}</span>
<button
class="wf-error-dismiss"
on:click={() => (workflowError = null)}
aria-label="Dismiss error"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/>
</svg>
</button>
</div>
{/if}
<!-- Action Buttons -->
{#if !isTerminal && allActions.length > 0}
<div class="wf-actions">
<!-- Primary forward action (filled button) -->
{#if primaryAction}
{@const isSendGated =
primaryAction.action === "sendQuote" && !!sendQuoteDisabledReason}
{@const isResendGated =
primaryAction.action === "resendQuote" &&
!!resendQuoteDisabledReason}
{@const gatedReason = isSendGated
? sendQuoteDisabledReason
: isResendGated
? resendQuoteDisabledReason
: null}
{#if gatedReason}
<span class="wf-tooltip-wrap" data-tooltip={gatedReason}>
<button class="wf-action-btn wf-btn-primary" disabled
>{actionLabel(primaryAction)}</button
>
</span>
{:else}
<button
class="wf-action-btn wf-btn-primary"
on:click={() => openAction(primaryAction)}
disabled={isSubmitting}>{actionLabel(primaryAction)}</button
>
{/if}
{/if}
<!-- Secondary actions + finalize (ghost buttons) -->
{#if secondaryActions.length > 0 || finalizeWonAction || finalizeLostAction}
{#if primaryAction}
<span class="wf-action-sep" aria-hidden="true"></span>
{/if}
{#each secondaryActions as action, i (action.action + "-" + i)}
{@const secSendGated =
action.action === "sendQuote" && !!sendQuoteDisabledReason}
{@const secResendGated =
action.action === "resendQuote" && !!resendQuoteDisabledReason}
{@const secGatedReason = secSendGated
? sendQuoteDisabledReason
: secResendGated
? resendQuoteDisabledReason
: null}
{#if secGatedReason}
<span class="wf-tooltip-wrap" data-tooltip={secGatedReason}>
<button class="wf-action-btn wf-btn-ghost" disabled
>{actionLabel(action)}</button
>
</span>
{:else}
<button
class="wf-action-btn wf-btn-ghost"
class:wf-ghost-danger={action.action === "cancel"}
on:click={() => openAction(action)}
disabled={isSubmitting}>{actionLabel(action)}</button
>
{/if}
{/each}
{#if finalizeWonAction}
<button
class="wf-action-btn wf-btn-ghost wf-ghost-success"
on:click={() => {
preselectedOutcome = "won";
openAction(finalizeWonAction);
}}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"><polyline points="20 6 9 17 4 12" /></svg
>
Won
</button>
{/if}
{#if finalizeLostAction}
<button
class="wf-action-btn wf-btn-ghost wf-ghost-danger"
on:click={() => {
preselectedOutcome = "lost";
openAction(finalizeLostAction);
}}
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="11"
height="11"
><line x1="18" y1="6" x2="6" y2="18" /><line
x1="6"
y1="6"
x2="18"
y2="18"
/></svg
>
Lost
</button>
{/if}
{/if}
</div>
{/if}
<!-- Terminal / Locked indicator -->
{#if isTerminal}
<div class="wf-terminal-notice">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path
d="M7 11V7a5 5 0 0110 0v4"
/>
</svg>
This opportunity is finalized and locked.
</div>
{/if}
</div>
{/if}
<!-- ── Modals ── -->
<!-- Generic action modal (requestReview, confirmQuote, beginRevision, resurrect, cancel, reopen, acceptNew) -->
{#if activeModal && !isSpecialModal(activeModal)}
{@const action = allActions.find((a) => a.action === activeModal)}
{#if action}
<WorkflowActionModal
isOpen={true}
title={actionLabel(action)}
actionName={action.action}
requiresNote={action.requiresNote}
needsConfirmation={needsConfirmation(action.action)}
confirmationMessage={confirmationMessage(action.action)}
isDestructive={action.action === "cancel"}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction(action.action, e.detail)}
on:close={closeModal}
/>
{/if}
{/if}
<!-- SendQuote modal -->
{#if activeModal === "sendQuote" || activeModal === "resendQuote"}
<SendQuoteModal
isOpen={true}
{canFinalize}
error={workflowError}
{isSubmitting}
actionName={activeModal}
on:submit={(e) => submitAction(activeModal ?? "sendQuote", e.detail)}
on:close={closeModal}
/>
{/if}
<!-- ReviewDecision modal -->
{#if activeModal === "reviewDecision"}
<ReviewDecisionModal
isOpen={true}
{canCancel}
{canSend}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction("reviewDecision", e.detail)}
on:sendQuote={() => {
activeModal = "sendQuote";
}}
on:close={closeModal}
/>
{/if}
<!-- Finalize modal -->
{#if activeModal === "finalize"}
{@const wonAction = finalizeWonAction}
{@const lostAction = finalizeLostAction}
<FinalizeModal
isOpen={true}
{statusKey}
hasWon={!!wonAction}
hasLost={!!lostAction}
{canFinalize}
initialOutcome={preselectedOutcome}
error={workflowError}
{isSubmitting}
on:submit={(e) => submitAction("finalize", e.detail)}
on:close={closeModal}
/>
{/if}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
mockOptima: {
sales: { createNote: vi.fn() },
},
mockJson: vi.fn((data, init?) => {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
});
}),
mockError: vi.fn((status: number, message: string) => {
throw { status, body: { message } };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
import { POST } from "./+server";
describe("POST /sales/opportunity/[id]/notes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("throws 401 when no access token", async () => {
const event = {
locals: {},
params: { id: "opp-1" },
request: {
json: vi.fn().mockResolvedValue({ text: "hello" }),
},
};
await expect(POST(event as any)).rejects.toBeDefined();
});
it("throws 400 when text is empty", async () => {
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: {
json: vi.fn().mockResolvedValue({ text: " " }),
},
};
await expect(POST(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
);
});
it("creates note successfully", async () => {
const created = { id: 1, text: "A note" };
mockOptima.sales.createNote.mockResolvedValueOnce(created);
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: {
json: vi.fn().mockResolvedValue({ text: "A note", flagged: true }),
},
};
await POST(event as any);
expect(mockOptima.sales.createNote).toHaveBeenCalledWith("tok", "opp-1", {
text: "A note",
flagged: true,
});
expect(mockJson).toHaveBeenCalledWith(created, { status: 201 });
});
it("defaults flagged to false", async () => {
mockOptima.sales.createNote.mockResolvedValueOnce({ id: 1 });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: {
json: vi.fn().mockResolvedValue({ text: "Note" }),
},
};
await POST(event as any);
expect(mockOptima.sales.createNote).toHaveBeenCalledWith("tok", "opp-1", {
text: "Note",
flagged: false,
});
});
});
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
mockOptima: {
sales: {
updateNote: vi.fn(),
deleteNote: vi.fn(),
},
},
mockJson: vi.fn((data, init?) => {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
});
}),
mockError: vi.fn((status: number, message: string) => {
throw { status, body: { message } };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
import { PATCH, DELETE } from "./+server";
describe("/sales/opportunity/[id]/notes/[noteId]", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("PATCH", () => {
it("throws 401 when no access token", async () => {
const event = {
locals: {},
params: { id: "opp-1", noteId: "5" },
request: { json: vi.fn().mockResolvedValue({ text: "updated" }) },
};
await expect(PATCH(event as any)).rejects.toBeDefined();
});
it("throws 400 for invalid noteId", async () => {
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "abc" },
request: { json: vi.fn().mockResolvedValue({ text: "updated" }) },
};
await expect(PATCH(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
);
});
it("throws 400 when neither text nor flagged provided", async () => {
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "5" },
request: { json: vi.fn().mockResolvedValue({}) },
};
await expect(PATCH(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
);
});
it("updates note text", async () => {
mockOptima.sales.updateNote.mockResolvedValueOnce({ id: 5 });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "5" },
request: {
json: vi.fn().mockResolvedValue({ text: "updated text" }),
},
};
await PATCH(event as any);
expect(mockOptima.sales.updateNote).toHaveBeenCalledWith(
"tok",
"opp-1",
5,
{ text: "updated text" },
);
});
it("updates note flagged status", async () => {
mockOptima.sales.updateNote.mockResolvedValueOnce({ id: 5 });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "5" },
request: {
json: vi.fn().mockResolvedValue({ flagged: true }),
},
};
await PATCH(event as any);
expect(mockOptima.sales.updateNote).toHaveBeenCalledWith(
"tok",
"opp-1",
5,
{ flagged: true },
);
});
});
describe("DELETE", () => {
it("throws 401 when no access token", async () => {
const event = {
locals: {},
params: { id: "opp-1", noteId: "5" },
};
await expect(DELETE(event as any)).rejects.toBeDefined();
});
it("throws 400 for invalid noteId", async () => {
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "abc" },
};
await expect(DELETE(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
);
});
it("deletes note successfully", async () => {
mockOptima.sales.deleteNote.mockResolvedValueOnce({ success: true });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1", noteId: "5" },
};
await DELETE(event as any);
expect(mockOptima.sales.deleteNote).toHaveBeenCalledWith(
"tok",
"opp-1",
5,
);
expect(mockJson).toHaveBeenCalledWith({ success: true });
});
});
});
+64 -11
View File
@@ -1,6 +1,32 @@
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
import type {
CommittedQuote,
WorkflowStatusResponse,
WorkflowHistoryResponse,
WorkflowStatusKey,
} from "$lib/optima-api/modules/sales";
import {
WORKFLOW_STATUS_IDS,
STATUS_ID_TO_KEY,
WORKFLOW_STATUS_LABELS,
TERMINAL_STATUSES,
REOPENABLE_STATUSES,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
export {
WORKFLOW_STATUS_IDS,
STATUS_ID_TO_KEY,
WORKFLOW_STATUS_LABELS,
TERMINAL_STATUSES,
REOPENABLE_STATUSES,
};
export type {
WorkflowStatusResponse,
WorkflowHistoryResponse,
WorkflowStatusKey,
};
export interface OpportunityForecast {
id: number;
forecastType?: string;
@@ -113,8 +139,10 @@ export interface PageData {
notes: OpportunityNote[];
contacts: OpportunityContact[];
products: OpportunityProduct[];
quotes: CommittedQuote[];
accessToken: string | null;
permissions: PermissionMap;
workflowStatus: WorkflowStatusResponse | null;
}
export function opportunityInitials(name: string): string {
@@ -158,36 +186,61 @@ const STATUS_TIER: Record<number, string> = (() => {
const map: Record<number, string> = {};
// FutureLead (id 51) + equivalencies
for (const id of [51, 35, 36]) map[id] = "status-future";
// New (id 24) + equivalencies
for (const id of [24, 1, 13, 37]) map[id] = "status-new";
// PendingNew (id 37)
map[37] = "status-pending-new";
// New (id 24) + equivalencies (37 removed — now its own tier)
for (const id of [24, 1, 13]) map[id] = "status-new";
// Internal Review (id 56) + equivalencies
for (const id of [56, 10, 26, 27, 28, 41, 54]) map[id] = "status-review";
// Active (id 58) + equivalencies
// QuoteSent (id 43)
map[43] = "status-quote-sent";
// ConfirmedQuote (id 57)
map[57] = "status-quote-confirmed";
// PendingSent (id 60)
map[60] = "status-pending-sent";
// PendingRevision (id 61)
map[61] = "status-pending-revision";
// Active (id 58) + equivalencies (43, 57 removed — now own tiers)
for (const id of [
58, 9, 15, 16, 17, 18, 19, 20, 25, 43, 38, 39, 40, 42, 44, 45, 46, 47, 48,
52, 55, 57,
58, 9, 15, 16, 17, 18, 19, 20, 25, 38, 39, 40, 42, 44, 45, 46, 47, 48, 52,
55,
])
map[id] = "status-active";
// Won (id 29) + equivalencies
for (const id of [29, 2, 49]) map[id] = "status-won";
// Lost (id 53) + equivalencies
for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34, 50])
map[id] = "status-lost";
// PendingWon (id 49)
map[49] = "status-pending-won";
// Won (id 29) + equivalencies (49 removed — now own tier)
for (const id of [29, 2]) map[id] = "status-won";
// PendingLost (id 50)
map[50] = "status-pending-lost";
// Lost (id 53) + equivalencies (50 removed — now own tier)
for (const id of [53, 3, 4, 12, 30, 31, 32, 33, 34]) map[id] = "status-lost";
// Canceled (id 59)
map[59] = "status-canceled";
return map;
})();
/** Canonical display name for each tier */
const CANONICAL_NAMES: Record<number, string> = {
51: "FutureLead",
37: "Pending New",
24: "New",
56: "Internal Review",
43: "Quote Sent",
57: "Confirmed Quote",
60: "Pending Sent",
61: "Pending Revision",
58: "Active",
49: "Pending Won",
29: "Won",
50: "Pending Lost",
53: "Lost",
59: "Canceled",
};
/** IDs that are canonical (not equivalency-mapped) */
const CANONICAL_IDS = new Set([51, 24, 56, 58, 29, 53]);
const CANONICAL_IDS = new Set([
51, 37, 24, 56, 43, 57, 60, 61, 58, 49, 29, 50, 53, 59,
]);
export function statusColorClass(opportunity: SalesOpportunity): string {
if (opportunity.closedFlag) {
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
mockOptima: {
sales: {
fetchWorkflowStatus: vi.fn(),
dispatchWorkflowAction: vi.fn(),
},
},
mockJson: vi.fn((data, init?) => {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
});
}),
mockError: vi.fn((status: number, message: string) => {
throw { status, body: { message } };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
import { GET, POST } from "./+server";
describe("/sales/opportunity/[id]/workflow", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("GET", () => {
it("throws 401 when no access token", async () => {
const event = { locals: {}, params: { id: "opp-1" } };
await expect(GET(event as any)).rejects.toBeDefined();
});
it("returns workflow status", async () => {
mockOptima.sales.fetchWorkflowStatus.mockResolvedValueOnce({
data: { state: "draft" },
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
};
await GET(event as any);
expect(mockOptima.sales.fetchWorkflowStatus).toHaveBeenCalledWith(
"tok",
"opp-1",
);
expect(mockJson).toHaveBeenCalledWith({ data: { state: "draft" } });
});
it("throws on failure", async () => {
mockOptima.sales.fetchWorkflowStatus.mockRejectedValueOnce({
status: 500,
response: { data: { message: "Internal error" } },
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
};
await expect(GET(event as any)).rejects.toBeDefined();
});
});
describe("POST", () => {
it("throws 401 when no access token", async () => {
const event = {
locals: {},
params: { id: "opp-1" },
request: { json: vi.fn().mockResolvedValue({ action: "approve" }) },
};
await expect(POST(event as any)).rejects.toBeDefined();
});
it("throws 400 when action is missing", async () => {
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: { json: vi.fn().mockResolvedValue({}) },
};
await expect(POST(event as any)).rejects.toEqual(
expect.objectContaining({ status: 400 }),
);
});
it("dispatches workflow action successfully", async () => {
mockOptima.sales.dispatchWorkflowAction.mockResolvedValueOnce({
data: { state: "approved" },
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: {
json: vi
.fn()
.mockResolvedValue({ action: "approve", payload: { note: "ok" } }),
},
};
await POST(event as any);
expect(mockOptima.sales.dispatchWorkflowAction).toHaveBeenCalledWith(
"tok",
"opp-1",
"approve",
{ note: "ok" },
);
expect(mockJson).toHaveBeenCalledWith({ data: { state: "approved" } });
});
it("returns error response data on workflow failure", async () => {
mockOptima.sales.dispatchWorkflowAction.mockRejectedValueOnce({
status: 422,
response: {
data: {
status: 422,
message: "Cannot transition",
errors: ["Invalid state"],
},
},
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
request: {
json: vi.fn().mockResolvedValue({ action: "approve" }),
},
};
await POST(event as any);
expect(mockJson).toHaveBeenCalledWith(
{
status: 422,
message: "Cannot transition",
errors: ["Invalid state"],
},
{ status: 422 },
);
});
});
});
@@ -0,0 +1,72 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /sales/opportunity/[id]/workflow — fetch workflow status */
export const GET: RequestHandler = async ({ params, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
try {
const result = await optima.sales.fetchWorkflowStatus(
accessToken,
params.id,
);
return json(result);
} catch (err: unknown) {
console.error("[Workflow] Failed to fetch status:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
const message =
err && typeof err === "object" && "response" in err
? ((err as { response?: { data?: { message?: string } } }).response
?.data?.message ?? "Failed to fetch workflow status")
: "Failed to fetch workflow status";
throw error(status, message);
}
};
/** POST /sales/opportunity/[id]/workflow — dispatch workflow action */
export const POST: RequestHandler = async ({ params, request, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const body = await request.json();
if (!body.action) throw error(400, "Action is required");
try {
const result = await optima.sales.dispatchWorkflowAction(
accessToken,
params.id,
body.action,
body.payload ?? {},
);
return json(result);
} catch (err: unknown) {
console.error("[Workflow] Action failed:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
// Extract the error response data for workflow failures
let responseData: Record<string, unknown> | undefined;
if (err && typeof err === "object" && "response" in err) {
const axiosErr = err as {
response?: { data?: Record<string, unknown>; status?: number };
};
responseData = axiosErr.response?.data;
}
// Return the full workflow error response so the UI can display it
if (responseData) {
return json(responseData, {
status: (responseData.status as number) ?? status,
});
}
throw error(status, "Workflow action failed");
}
};
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
mockOptima: {
sales: { fetchWorkflowHistory: vi.fn() },
},
mockJson: vi.fn((data, init?) => {
return new Response(JSON.stringify(data), {
status: init?.status ?? 200,
});
}),
mockError: vi.fn((status: number, message: string) => {
throw { status, body: { message } };
}),
}));
vi.mock("$lib", () => ({ optima: mockOptima }));
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
import { GET } from "./+server";
describe("GET /sales/opportunity/[id]/workflow/history", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("throws 401 when no access token", async () => {
const event = {
locals: {},
params: { id: "opp-1" },
url: new URL("http://localhost/workflow/history"),
};
await expect(GET(event as any)).rejects.toBeDefined();
});
it("fetches workflow history with type param", async () => {
mockOptima.sales.fetchWorkflowHistory.mockResolvedValueOnce({
data: [{ action: "approve" }],
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
url: new URL("http://localhost/workflow/history?type=approval"),
};
await GET(event as any);
expect(mockOptima.sales.fetchWorkflowHistory).toHaveBeenCalledWith(
"tok",
"opp-1",
"approval",
);
expect(mockJson).toHaveBeenCalledWith({
data: [{ action: "approve" }],
});
});
it("passes undefined when type param is absent", async () => {
mockOptima.sales.fetchWorkflowHistory.mockResolvedValueOnce({ data: [] });
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
url: new URL("http://localhost/workflow/history"),
};
await GET(event as any);
expect(mockOptima.sales.fetchWorkflowHistory).toHaveBeenCalledWith(
"tok",
"opp-1",
undefined,
);
});
it("throws on failure with API error message", async () => {
mockOptima.sales.fetchWorkflowHistory.mockRejectedValueOnce({
status: 500,
response: { data: { message: "Server broke" } },
});
const event = {
locals: { session: { accessToken: "tok" } },
params: { id: "opp-1" },
url: new URL("http://localhost/workflow/history"),
};
await expect(GET(event as any)).rejects.toBeDefined();
});
});
@@ -0,0 +1,32 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /sales/opportunity/[id]/workflow/history — fetch workflow activity history */
export const GET: RequestHandler = async ({ params, url, locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) throw error(401, "Unauthorized");
const type = url.searchParams.get("type") ?? undefined;
try {
const result = await optima.sales.fetchWorkflowHistory(
accessToken,
params.id,
type,
);
return json(result);
} catch (err: unknown) {
console.error("[Workflow] Failed to fetch history:", err);
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: 500;
const message =
err && typeof err === "object" && "response" in err
? ((err as { response?: { data?: { message?: string } } }).response
?.data?.message ?? "Failed to fetch workflow history")
: "Failed to fetch workflow history";
throw error(status, message);
}
};