feat: add workflow actions, admin enhancements, and comprehensive test coverage
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user